@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/fiberTransport 接口
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 | 方向 | 其余字段 | 语义 |
|---|---|---|---|
presence | peer → 全房间 | position: [x,y,z],rotationY?: number(弧度),meta?: object | 本地玩家 transform 心跳 |
bye | peer → 全房间 | — | 优雅离开,接收方立即剔除该 peer |
event | peer → 全房间 | 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 倍——默认
intervalMs100ms 对应 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 其他系统包。