@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)
}
})
})参考中继服务器
仓库内 examples/ws-server 是上面契约的完整参考实现(node server.mjs,
PORT 环境变量,默认 8787):按 URL 路径分房间(ws://host:8787/room-a
只与同路径的客户端互转)、30 秒心跳剔除死连接、SIGINT 优雅退出。它是
纯中继——不解析信封、不做权威仲裁;移动校验、防作弊等权威逻辑属于你
自己的游戏服务器。详见该目录的 README。
在线状态复制(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, // 超时剔除(默认)
})
sync.start()机制:
- 每个心跳读取
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 其他系统包。