Overworld
包参考

@overworld-engine/net

Transport 无关的多人同步抽象:presence 复制与事件中继

Transport 无关的多人同步抽象:统一的 Transport 接口 + 三个参考实现, 加上在此之上的在线状态复制(presence,把每个远端玩家镜像进 zustand store)、 事件中继(relay,把总线事件广播给所有对端)与输入预测 + 服务器对账 (prediction,配权威服务器消除本地操作延迟)。渲染侧提供简单平滑与可选的 快照插值缓冲。

安装

pnpm add @overworld-engine/net @overworld-engine/core
# peers: react zustand three @react-three/fiber

Transport 接口

interface Transport {
  readonly peerId: string                              // 本地 peer 的稳定 id
  send(data: unknown): void                            // 广播给所有其他 peer
  subscribe(cb: (msg: { from: string; data: unknown }) => void): () => void
  close(): void
}

三个参考实现(payload 必须可 JSON 序列化):

  • createLocalTransportHub() —— 进程内 hub,hub.createTransport(peerId?) 创建互联的 transport,同步投递(测试确定性)。用于单测与本地演示。
  • createBroadcastChannelTransport({ channelName, peerId? }) —— 同源多标签页 互联,零服务器。环境不支持时抛出明确错误;先用 isBroadcastChannelAvailable() 探测。
  • createWebSocketTransport({ url, peerId?, protocols?, reconnect?, WebSocketImpl? }) —— 每条消息一个 JSON 信封 { from, data }。CONNECTING 期间的 send 会缓冲、 open 后按序冲刷;意外断线后按 reconnect(默认 3 次 / 1000ms,成功后重置) 重连;close() 停止一切。非浏览器环境通过 WebSocketImpl 注入实现(如 ws)。

WebSocket 服务端契约

服务端唯一职责:把收到的消息原样广播给其他所有客户端。Node ws 示例:

import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (socket) => {
  socket.on('message', (raw) => {
    const text = raw.toString()
    for (const client of wss.clients) {
      if (client !== socket && client.readyState === 1) client.send(text)
    }
  })
})

参考中继服务器:@overworld-engine/relay

上面契约的生产可用实现以独立包发布:

npx @overworld-engine/relay      # overworld-relay,默认 8787;PORT / HEARTBEAT_MS 环境变量
import { createRelayServer } from '@overworld-engine/relay'

const relay = createRelayServer({
  port: 8787,                  // 或 server: 既有 http.Server(可配 path 前缀)
  heartbeatMs: 30_000,         // 心跳剔除死连接;0 关闭
  maxPayloadBytes: 64 * 1024,  // 超限连接以 1009 关闭
  onJoin: (room, n) => {},     // onLeave 同形
})
await relay.ready              // relay.port / relay.rooms() / await relay.close()

按 URL 路径分房间(ws://host:8787/room-a 只与同路径的客户端互转)、心跳 剔除死连接、SIGINT 优雅退出;仓库内 examples/ws-server 是它的薄包装示例。 它是纯中继——不解析信封、不做权威仲裁;移动校验、防作弊等权威逻辑属于 你自己的游戏服务器(见指南「权威多人」)。

线路协议规范(wire protocol)

以下规范精确到可以用任何语言实现兼容的中继或权威服务器。协议随 net 1.x 发布;2.0 之前只做加法(新增 t 种类),已有信封的字段与语义保持稳定, 实现方对未知 t 应当忽略。

传输层信封

  • 每条 WebSocket 消息 = 一个 JSON 文本帧:{ "from": string, "data": unknown }
  • from 是发送方 peerId,由客户端自行生成(优先 crypto.randomUUID(), 否则"计数器 + 时间戳"兜底)。没有握手、没有服务器分配 id:peer 通过第一条 消息的 from 隐式宣告自己,中继不参与 id 分配。
  • 中继 MUST:把每条消息原样转发给同一房间内所有其他 OPEN 连接; MUST NOT 回送给发送者(客户端虽有 msg.from === peerId 的兜底过滤, 但事件中继的防回声依赖"不回送",不能指望兜底)。
  • 中继不得解析或改写 data —— 信封对服务器完全不透明。客户端会静默忽略 非文本帧、无法解析的 JSON、以及缺 from 的消息。

房间

  • 房间 = 连接时的 URL 路径:wss://host/room-a;省略路径 = 默认房间 /
  • 没有 join/leave 帧:连接即加入、断开即离开。同房间互转,不同房间隔离。

应用层信封(按 data.t 多路复用)

data 是带判别字段 t 的对象;内建种类可共用同一个 transport,自定义信封 只需选一个不冲突的 t(未知 t 被各订阅者忽略):

t方向其余字段语义
presencepeer → 全房间position: [x,y,z],rotationY?: number(弧度),meta?: object本地玩家 transform 心跳
byepeer → 全房间优雅离开,接收方立即剔除该 peer
eventpeer → 全房间event: string,payload: unknown总线事件中继,接收方本地 re-emit
input客户端 → 权威端seq: number,input: unknown,dtMs: number预测输入上报
state权威端 → 客户端state: unknown,lastSeq: number权威状态 ack

presence 节奏(以下均为默认值,可配):发送方每 intervalMs = 100ms 读一次 本地 transform,有变化才发送,静止时每第 5 拍强制发一次 keepalive(即每 500ms 一包);接收方每收到一个 presence 包刷新该 peer 的 lastSeenAt,静默超过 staleAfterMs = 3000ms 即剔除(视同离线);收到 bye 立即剔除。首个 presence 包即宣告加入 —— 没有显式 join。

event 语义:emit → 广播 → 各对端 re-emit;re-emit 期间以重入标记抑制转发 (echo suppression),因此一次 emit 在每个 peer 恰好出现一次,不放大—— 其前提正是中继不回送给发送者。

input/state(prediction 通道)语义:seq 由客户端从 1 起单调递增;权威端 处理输入后以 lastSeq =「已处理的最高 seq」回 state;客户端收到 lastSeq <= 已确认 seq 的过期/乱序 ack 时整体忽略,否则回退到 state 并按序 重放所有 seq > lastSeq 的未确认输入。权威端自定义的额外广播(如 examples/authority-server 的 { t: 'world', players })就是"自定义 t"的例子。

自建兼容中继的最小要求

  • WebSocket 端点,按 URL 路径分房间(至少支持默认房间 /)
  • 把每个文本帧原样转发给同房间所有其他 OPEN 连接
  • 绝不回送给发送者
  • 不解析、不改写消息;同一连接的消息保持到达顺序
  • 连接断开即离开房间;建议 ping/pong 心跳(参考 30s)剔除死连接
  • 不需要:握手、id 分配、房间管理帧、持久化 —— 协议里都不存在

版本与稳定性承诺

信封结构 { from, data }、房间语义与上表内建 t 是稳定接口:2.0 之前只会 新增 t 种类,不改字段、不改语义。

在线状态复制(presence)

import { createPresenceSync, createBroadcastChannelTransport } from '@overworld-engine/net'
import { getPlayerPosition, playerRotationRef } from '@overworld-engine/scene'

const sync = createPresenceSync({
  transport: createBroadcastChannelTransport({ channelName: 'my-game' }),
  getLocal: () => ({
    position: getPlayerPosition(),
    rotationY: playerRotationRef.current,
    meta: { name: '玩家甲' },
  }),
  intervalMs: 100,      // 心跳间隔(默认)
  staleAfterMs: 3000,   // 超时剔除(默认)
  // clock: () => number,默认 Date.now;lastSeenAt、超时剔除与插值缓冲共用这一个时基
})
sync.start()

确定性:同 seed 重放/确定性测试需注入 clock(否则 lastSeenAt 写入墙钟时间, 超时剔除依赖真实时间);引擎值层面无 Math.random(peer id 的 crypto.randomUUID 兜底可通过各 Transport 配置的 peerId 显式指定绕开)。

机制:

  • 每个心跳读取 getLocal(),有变化才发送;静止时每第 5 拍发一次 keepalive (默认即每 500ms 一包),既省带宽又保证不被误判超时,迟到的 peer 也能在 500ms 内看到你。
  • sync.store 是 zustand vanilla store,状态就是 Record<peerId, RemotePeer> ({ peerId, position, rotationY, meta?, lastSeenAt })。sync.peers() 取数组快照。
  • 首次收到某 peer 的消息 → 入库并在事件总线(默认 gameEvents,可注入)上发 net:peer-joined { peerId };静默超过 staleAfterMs → 剔除并发 net:peer-left { peerId };stop() 会广播 bye,让对端立即剔除而不必等超时。
  • 事件表通过 declaration merging 扩展,net:* 事件在任何 bus 上都有完整类型。

快照插值(snapshot interpolation)

默认的渲染平滑是朝最新位置做指数插值:局域网上足够顺滑,但真实网络的 抖动(包间隔忽长忽短)会直接体现为远端玩家的顿挫。经典解法是延迟缓冲插值: 把收到的每个快照打上本地时间戳存进缓冲,渲染时统一采样"delayMs 毫秒之前" 的时间点——那一刻几乎总有两个快照可以夹住插值,运动因此连续。

const sync = createPresenceSync({
  transport,
  getLocal: () => ({ position: getPlayerPosition() }),
  interpolation: { delayMs: 120 },   // 默认关闭;传 {} 即启用(delayMs 默认 120)
})

sync.samplePeer('peer-id')   // { position, rotationY } | null(线性位置 + 最短弧角度)
  • 何时启用:走真实网络(WebSocket / 互联网)时建议启用;本地 hub、 BroadcastChannel 或局域网演示用默认平滑即可,还省一份固定延迟。
  • delayMs 取舍:值越大越能吃掉抖动(晚到的包仍落在采样点之后),但远端 玩家的动作就恒定晚这么多。经验值取发送间隔的 1.5–2 倍——默认 intervalMs 100ms 对应 delayMs 120–200。竞技性越强取越小,网络越差取越大。
  • 采样语义:缓冲为空或采样点早于首个快照 → null;只有一个快照 → 原样返回; 发送端停更、采样点超过最后一个快照 → 钳在最后一个(角色停在原地而不是外推)。
  • <RemotePlayers> 检测到 sync 启用了插值会自动改用 samplePeer 定位, props 不变;拿不到采样(刚加入)时回退到指数平滑。
  • 底层的 createSnapshotBuffer 单独导出,可给任何类型做延迟缓冲插值 (注入时钟、maxSnapshots 上限、调用方提供插值函数)。

输入预测与服务器对账(prediction & reconciliation)

权威服务器架构下,如果客户端等服务器确认才移动,每次按键都要吃一个整 RTT。 经典解法是客户端预测:本地立即用输入推进状态(零感知延迟),同时把输入 连同序号发给服务器;服务器权威地跑同样的模拟并回 ack;客户端收到 ack 后 回退到服务器状态,重放所有尚未确认的输入——预测 → 发送 → 确认 → 回退 → 重放,循环往复。只要模拟是确定性的,重放结果与本地预测完全一致,玩家毫无感知。

import { createPredictedState, createInputChannel } from '@overworld-engine/net'

// 纯确定性 step:客户端与服务器必须逐字运行同一份
const step = (s, { dx, dz }, dtMs) => ({ x: s.x + dx * 5 * (dtMs / 1000), z: /* … */ })

const predicted = createPredictedState({
  initialState: { x: 0, z: 0 },
  step,
  maxPending: 128,                    // 未确认输入上限,超出丢最旧并告警一次
  onCorrection: (before, after) => {} // 仅在预测被服务器推翻时触发
})
const channel = createInputChannel(transport)   // 信封 t: 'input' | 'state'
channel.onServerState((state, lastSeq) => predicted.onServerState(state, lastSeq))

// 每帧:
const seq = predicted.applyInput({ dx, dz }, dtMs)  // 本地立即前进,返回单调序号
channel.sendInput(seq, { dx, dz }, dtMs)
render(predicted.state)                              // 渲染永远用预测态
  • 确定性是硬性前提:step 必须是纯函数——同状态 + 同输入 + 同 dt 在两端 得到同一结果。随机数、读系统时钟、依赖外部可变状态都会让每次 ack 变成一次 可见的纠偏。
  • onCorrection 语义:对账后仅当重放结果 ≠ 对账前的预测态才触发(比较函数 可用 equals 注入;默认对两个状态做 JSON.stringify 全量比较——纯数据状态 正确但每次 ack 都要序列化,热路径建议传逐字段/带 epsilon 的比较)。触发即说明 服务器不同意你的预测(钳制、纠正或丢了输入),after 已被设为当前 state, 拿它做视觉上的 snap 或混合。之后的重放自动基于纠正后的状态。
  • 过期/乱序的 ack(lastProcessedSeq <= lastAckedSeq)被整体忽略;reset(state) 清空未确认队列硬设状态(重生/传送),序号保持单调。
  • 信封 t: 'input' | 'state' 与 presence/relay 命名空间互不冲突,可共用一个 transport。

参考权威服务器

仓库内 examples/authority-server(node server.mjs,默认 8788)是配套的 权威移动服务器:持有全部玩家位置,用与客户端相同的 step 处理输入,20Hz 回 { t: 'state', state, lastSeq } 给本人、10Hz 广播 { t: 'world', players } 给全员;并演示服务端校验——|dx|, |dz| 钳到 ≤ 1、dtMs 钳到 ≤ 100ms, 外挂输入照常被钳制、客户端在下一次对账被拉回(onCorrection 触发)。 详见该目录的 README。

事件中继(relay)

import { gameEvents } from '@overworld-engine/core'
import { relayEvents } from '@overworld-engine/net'

const unbind = relayEvents(gameEvents, transport, {
  events: ['quest:started', 'market:trade'],
})

列出的事件在本地 emit 后广播给所有对端并在对端重放。防回声:重放期间置 重入标记、不再转发,因此一次 emit 在每个 peer 恰好出现一次,绝不放大。 payload 必须可 JSON 序列化。presence 与 relay 的信封用 t 字段区分 ('presence' | 'bye' | 'event'),可以共用同一个 transport。

渲染远端玩家

import { RemotePlayers } from '@overworld-engine/net'

<Canvas>
  <RemotePlayers sync={sync} lerp={0.15} />
  {/* 或自定义外观(仅在 peer 加入/离开时重渲染): */}
  <RemotePlayers sync={sync} renderPeer={(peer) => <Avatar name={peer.meta?.name} />} />
</Canvas>

每个远端 peer 一个 <group>,位置/朝向在 useFrame 中向最新数据做指数平滑 (最短弧旋转),心跳不触发 React 重渲染,无逐帧分配。默认外观是半透明胶囊体。

分层约定

本包只依赖 @overworld-engine/core;与 scene 包通过结构化类型协作 (getLocal 的形状即 playerStore 的形状),不 import 其他系统包。

本页目录