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)
    }
  })
})

参考中继服务器

仓库内 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 倍——默认 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 其他系统包。

本页目录