文档
🌐 语言:
本地静态HTML @p2play-js/p2p-game 主题
⚠️ 警告:此文档是从英文自动翻译的。可能存在错误。 英文原版

介绍

@p2play-js/p2p-game 是一个模块化的TypeScript库,用于构建基于浏览器的P2P(WebRTC)多人游戏。它提供 状态同步(完整/增量)、一致性策略(时间戳/权威)、最小WebSocket信令适配器、移动助手、 主机选举/迁移和ping覆盖层。

快速开始

npm install @p2play-js/p2p-game
import { P2PGameLibrary, WebSocketSignaling } from "@p2play-js/p2p-game";

const signaling = new WebSocketSignaling("玩家A", "房间-42", "wss://你的-ws.示例");
const multiP2PGame = new P2PGameLibrary({
  signaling,
  maxPlayers: 4,
  syncStrategy: "delta",
  conflictResolution: "timestamp",
});

await multiP2PGame.start();

multiP2PGame.on("playerMove", (id, pos) => {/* 渲染 */});

演示

架构

  • 该库使用 WebSocket 信令服务器来管理房间、维护玩家标识列表,并将 SDP/ICE 消息路由到特定对端。
  • 对端形成全互联网格:对于每一对对端,按字典序更小的 playerId 负责创建 WebRTC offer,从而避免同时发起导致的冲突。
  • 当 DataChannel 建立后,游戏消息改为点对点传输;信令服务器不再转发应用层流量。
  • 主机选举是确定性的:最小的 playerId 成为主机;主机离开时,由次小者继任并发送全量快照。
什么是“信令”?
浏览器在打开 WebRTC 连接前,必须先通过带外通道交换元数据(SDP offer/answer 与 ICE 候选)。 信令服务器仅负责该交换并维护房间名册;DataChannel 打开后不再转发游戏数据。

信令时序

sequenceDiagram participant A as 客户端 A participant S as WS 信令 participant B as 客户端 B A->>S: register {roomId, from, announce} B->>S: register {roomId, from, announce} S-->>A: sys: roster [A,B] S-->>B: sys: roster [A,B] note over A,B: 最小的 playerId 发起 offer A->>S: kind: desc (offer), to: B S->>B: kind: desc (offer) from A B->>S: kind: desc (answer), to: A S->>A: kind: desc (answer) from B A->>S: kind: ice, to: B B->>S: kind: ice, to: A note over A,B: DataChannel 打开 → 游戏转为 P2P

全互联拓扑

graph LR A[玩家 A] --- B[玩家 B] A --- C[玩家 C] B --- C classDef host fill:#2b79c2,stroke:#2a3150,color:#fff; class A host;

状态同步

  • 全量快照:加入/迁移、纠偏重同步。
  • 增量更新:针对路径的局部变更(实践中多采用混合策略)。
全量 vs 增量
全量快照健壮简单但开销大;增量轻量高效但需要稳定的状态结构。实践中,多数情况用增量;当新对端加入或主机迁移后用一次全量。

一致性

  • 时间戳(默认):按发送方序列采用“最后写入胜”(LWW)。
  • 权威:仅接受权威端(主机或固定ID)的操作。
游戏中的一致性
时间戳模式下,按发送方维度接受最新动作,采用 LWW 规则:任何 seq 小于最近已见序号的消息将被忽略。在权威模式下,由一个对端(常为可信主机)应用所有动作以避免冲突与作弊;其他对端发送意图并接受纠正。

移动

本库旨在网络抖动下实现平滑且可预期的移动。它结合了插值(在已知采样间平滑过渡)与 限幅外推(短预测窗口),以在不偏离真实状态过多的前提下掩盖迟到更新。

插值

收到远端新位置时,我们不会瞬移至该位置;而是每帧推进剩余距离的一小部分。 smoothing 系数控制该比例(0..1)。值越大视觉延迟越小,但可能显得“漂浮”。

// 伪代码
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec 考虑外推上限(见下)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// 可选 Z 轴(如提供)
调节 smoothing
建议从 0.2–0.3 开始。若移动明显落后输入,可增大;若出现振荡或过冲,则减小。

外推(带上限)

若本帧没有收到新更新,则暂时以最近速度向前预测。为避免漂移,通过 extrapolationMs(如 120–140ms)限制预测窗口;超出预算后停止预测并等待下一次权威更新。

为何要限幅?
外推过久会产生明显错误(穿墙、纠正时瞬移)。短窗口可掩盖瞬时抖动,同时让视图保持接近真实。

2D 与 3D

默认位置与速度为二维;如需简单 3D,可添加 z。若定义了 worldBounds.depth,Z 轴同样会被限制。

世界边界 vs 开放世界

设定 worldBounds 时,位置被限制在 [0..width][0..height](若有 Z 则至 [0..depth])。 开放世界沙盒中,可设置 ignoreWorldBounds: true 以禁用所有限制(仅保留玩家间碰撞)。

碰撞(圆/球)

碰撞通过对半分离处理:圆(2D)/球(3D)半径相同,重叠时计算规范化向量并将双方沿该向量各推开一半重叠距离。简单且稳定,适合休闲类游戏。

// 两个玩家 A、B,半径 r
const dx = B.x - A.x, dy = B.y - A.y, dz = (B.z||0) - (A.z||0);
const dist = Math.max(1e-6, Math.hypot(dx, dy, dz));
const overlap = Math.max(0, 2*r - dist) / 2;
const nx = dx / dist, ny = dy / dist, nz = dz / dist;
A.x -= nx * overlap; A.y -= ny * overlap; A.z = (A.z||0) - nz * overlap;
B.x += nx * overlap; B.y += ny * overlap; B.z = (B.z||0) + nz * overlap;
            
复杂度说明
朴素算法会检查所有配对(O(n²)),小房间可接受。若场景拥挤,可在应用层加入空间划分(网格/象限树)。

流程:移动步骤

flowchart TD A[最近已知状态] --> B{是否有新网络更新?} B -- 是 --> C[重置时间戳;应用插值] B -- 否 --> D{外推预算是否充足?} D -- 是 --> E[按最近速度 * smoothing 外推] D -- 否 --> F[保持位置] C --> G{有世界边界?} E --> G G -- 限制 --> H[应用限制] G -- 开放世界 --> I[跳过限制] H --> J[处理碰撞] I --> J J --> K[渲染]

网络细节

  • 背压策略:对饱和通道进行合并/丢弃。
  • 容量:强制 maxPlayers 并触发 maxCapacityReached 事件。
  • STUN/TURN:在严格网络下提供 TURN;信令使用 WSS。
关于 NAT 与 TURN
许多企业/酒店网络会阻止直连。TURN 服务器可中继流量以便对端仍可连接,但会增加延迟与服务器带宽开销。 生产环境建议提供 TURN 凭证以提高可靠性。

背压

背压用于保护 DataChannel 不被过载。当通道内部发送缓冲(RTCDataChannel.bufferedAmount)超阈值时, 可以暂时停止发送、丢弃低价值消息,或将多条更新折叠为最新一条。

工作原理
每次 send() 都会增加 bufferedAmount,直到浏览器将数据刷到网络。 若发送速度长期高于网络承载能力,将导致延迟飙升与应用卡顿。以下策略可缓解。

策略

  • off:不启用背压。仅适用于很小且不频繁的消息。
  • drop-moves:超过阈值时忽略新的 move 消息(输入瞬态,丢弃通常可接受)。
  • coalesce-moves:队列中仅保留每个对端的最新 move,替换旧的。
const multiP2PGame = new P2PGameLibrary({
  signaling,
  backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
  
推荐阈值
从 256–512 KB 开始。若经常触发阈值,应降低消息频率或减小载荷(如使用增量、binary‑min、向量量化)。

事件和API(选摘)

  • on('playerMove')on('inventoryUpdate')on('objectTransfer')
  • on('stateSync')on('stateDelta')on('hostChange')on('ping')
  • broadcastMove()updateInventory()transferItem()
  • broadcastPayload()sendPayload()
  • setStateAndBroadcast()announcePresence()getHostId()

事件总览

事件 签名 说明
playerMove(playerId, position)已应用的移动
inventoryUpdate(playerId, items)库存已更新
objectTransfer(from, to, item)物品已转移
sharedPayload(from, payload, channel?)收到通用载荷
stateSync(state)收到全量快照
stateDelta(delta)收到状态增量
peerJoin(playerId)有对端加入
peerLeave(playerId)有对端离开
hostChange(hostId)主机变更
ping(playerId, ms)往返时延 (RTT)
maxCapacityReached(maxPlayers)达到容量上限;拒绝新连接

生命周期与在线状态

  • 在线状态:尽早调用 announcePresence(playerId) 发送一次初始移动,便于其他对端立即渲染该玩家。
  • peerJoin/peerLeave:UI 可据此显示/隐藏实体。主机侧可通过在 P2PGameLibrary 配置中启用 cleanupOnPeerLeave: true 来自动清理离开者并广播增量。
  • 容量上限:设置 maxPlayers 以限制房间大小。达到容量后,库将不再发起新连接并忽略额外的 offer;同时触发 maxCapacityReached(maxPlayers) 以便 UI 提示。

类型参考

type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta";
type ConflictResolution = "timestamp" | "authoritative";

interface BackpressureOptions {
  strategy?: "off" | "drop-moves" | "coalesce-moves";
  thresholdBytes?: number; // 默认约 256KB
}

interface DebugOptions {
  enabled?: boolean;
  onSend?: (info: {
    type: "broadcast" | "send";
    to: string | "all";
    payloadBytes: number;
    delivered: number;
    queued: number;
    serialization: SerializationStrategy;
    timestamp: number;
  }) => void;
}

interface MovementOptions {
  maxSpeed?: number;
  smoothing?: number; // 0..1
  extrapolationMs?: number;
  worldBounds?: { width: number; height: number; depth?: number };
  ignoreWorldBounds?: boolean;
  playerRadius?: number;
}

interface GameLibOptions {
  maxPlayers?: number;
  syncStrategy?: SyncStrategy; // 建议:由你决定何时发送 full vs delta
  conflictResolution?: ConflictResolution;
  authoritativeClientId?: string;
  serialization?: SerializationStrategy;
  iceServers?: RTCIceServer[];
  cleanupOnPeerLeave?: boolean;
  debug?: DebugOptions;
  backpressure?: BackpressureOptions;
  pingOverlay?: { enabled?: boolean; position?: "top-left"|"top-right"|"bottom-left"|"bottom-right"; canvas?: HTMLCanvasElement | null };
  movement?: MovementOptions;
}

Events

type EventMap = {
  playerMove: (playerId: string, position: { x:number; y:number; z?:number }) => void;
  inventoryUpdate: (playerId: string, items: Array<{ id:string; type:string; quantity:number }>) => void;
  objectTransfer: (fromId: string, toId: string, item: { id:string; type:string; quantity:number }) => void;
  stateSync: (state: GlobalGameState) => void;
  stateDelta: (delta: StateDelta) => void;
  peerJoin: (playerId: string) => void;
  peerLeave: (playerId: string) => void;
  hostChange: (hostId: string) => void;
  ping: (playerId: string, ms: number) => void;
  sharedPayload: (from: string, payload: unknown, channel?: string) => void;
  maxCapacityReached: (maxPlayers: number) => void;
};

interface GlobalGameState {
  players: Record;
  inventories: Record>;
  objects: Record }>;
  tick: number;
}

interface StateDelta { tick:number; changes: Array<{ path:string; value:unknown }> }

增量路径规则

  • 路径使用点分隔的对象键(不支持数组索引)。
  • 尽量保持结构扁平且可键控,以便定向更新(如 objects.chest.42),避免深层数组。
// 推荐:对象映射
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// 不支持:数组索引路径,如 'objects[3]' 或 'players.list.0'

P2PGameLibrary

构造函数

new P2PGameLibrary(options: GameLibOptions & { signaling: WebSocketSignaling | SignalingAdapter })

生命周期

await start(): Promise
on(name: N, handler: EventMap[N]): () => void
getState(): GlobalGameState
getHostId(): string | undefined
setPingOverlayEnabled(enabled: boolean): void
tick(now?: number): void // 每帧应用一次插值/碰撞

状态工具

setStateAndBroadcast(selfId: string, changes: Array<{ path:string; value:unknown }>): string[]
broadcastFullState(selfId: string): void
broadcastDelta(selfId: string, paths: string[]): void

游戏相关 API

announcePresence(selfId: string, position = { x:0, y:0 }): void
broadcastMove(selfId: string, position: {x:number;y:number;z?:number}, velocity?: {x:number;y:number;z?:number}): void
updateInventory(selfId: string, items: Array<{ id:string; type:string; quantity:number }>): void
transferItem(selfId: string, to: string, item: { id:string; type:string; quantity:number }): void

载荷 API

broadcastPayload(selfId: string, payload: unknown, channel?: string): void
sendPayload(selfId: string, to: string, payload: unknown, channel?: string): void

消息(传输)

// NetMessage 联合(节选)
type NetMessage =
  | { t:"move"; from:string; ts:number; seq?:number; position:{x:number;y:number;z?:number}; velocity?:{x:number;y:number;z?:number} }
  | { t:"inventory"; from:string; ts:number; seq?:number; items:Array<{id:string;type:string;quantity:number}> }
  | { t:"transfer"; from:string; ts:number; seq?:number; to:string; item:{id:string;type:string;quantity:number} }
  | { t:"state_full"; from:string; ts:number; seq?:number; state: GlobalGameState }
  | { t:"state_delta"; from:string; ts:number; seq?:number; delta: StateDelta }
  | { t:"payload"; from:string; ts:number; seq?:number; payload: unknown; channel?: string };

// 序列化
// 策略:"json"(字符串帧)或 "binary-min"(ArrayBuffer UTF-8 JSON)

信令适配器

库使用该抽象来通过任意后端(WebSocket、REST 等)交换 SDP/ICE。

interface SignalingAdapter {
  localId: string;
  roomId?: string;
  register(): Promise; // 加入房间并接收名册
  announce(desc: RTCSessionDescriptionInit, to?: string): Promise;
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void): void;
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void): void;
  onRoster(cb: (roster: string[]) => void): void;
  sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise;
}

示例:极简自定义适配器(WebSocket)

使用普通 WebSocket 信令服务器实现该接口。

class SimpleWsSignaling implements SignalingAdapter {
  constructor(public localId: string, public roomId: string, private url: string) {
    this.ws = new WebSocket(this.url);
  }
  private ws: WebSocket;
  private rosterCb?: (list: string[]) => void;
  private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
  private iceCb?: (c: RTCIceCandidateInit, from: string) => void;

  async register(): Promise {
    await new Promise((resolve) => {
      this.ws.addEventListener('open', () => {
        this.ws.send(JSON.stringify({ kind:'register', roomId: this.roomId, from: this.localId, announce:true }));
        resolve();
      });
    });
    this.ws.addEventListener('message', (ev) => {
      const msg = JSON.parse(ev.data);
      if (msg.sys === 'roster' && this.rosterCb) this.rosterCb(msg.roster);
      if (msg.kind === 'desc' && this.descCb) this.descCb(msg.payload, msg.from);
      if (msg.kind === 'ice' && this.iceCb) this.iceCb(msg.payload, msg.from);
    });
  }

  onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }

  async announce(desc: RTCSessionDescriptionInit, to?: string): Promise {
    this.ws.send(JSON.stringify({ kind:'desc', roomId:this.roomId, from:this.localId, to, payload: desc }));
  }
  async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise {
    this.ws.send(JSON.stringify({ kind:'ice', roomId:this.roomId, from:this.localId, to, payload: candidate }));
  }
}

// 结合库使用
const signaling = new SimpleWsSignaling('alice','room-1','wss://your-signal.example');
await signaling.register();
const multiP2PGame = new P2PGameLibrary({ signaling });
await multiP2PGame.start();

示例:REST + 长轮询适配器

在无 WebSocket 的环境中,使用 HTTP 接口 + 轮询接收消息。

class RestPollingSignaling implements SignalingAdapter {
  constructor(public localId: string, public roomId: string, private baseUrl: string) {}
  private rosterCb?: (list: string[]) => void;
  private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
  private iceCb?: (c: RTCIceCandidateInit, from: string) => void;
  private polling = false;

  async register(): Promise {
    await fetch(`${this.baseUrl}/register`, { method:'POST', headers:{ 'content-type':'application/json' }, body: JSON.stringify({ roomId:this.roomId, from:this.localId, announce:true }) });
    this.polling = true;
    void this.poll();
  }
  private async poll(): Promise {
    while (this.polling) {
      try {
        const res = await fetch(`${this.baseUrl}/poll?roomId=${encodeURIComponent(this.roomId)}&from=${encodeURIComponent(this.localId)}`);
        if (!res.ok) { await new Promise(r=>setTimeout(r,1000)); continue; }
        const msgs = await res.json();
        for (const msg of msgs) {
          if (msg.sys === 'roster' && this.rosterCb) this.rosterCb(msg.roster);
          if (msg.kind === 'desc' && this.descCb) this.descCb(msg.payload, msg.from);
          if (msg.kind === 'ice' && this.iceCb) this.iceCb(msg.payload, msg.from);
        }
      } catch {
        await new Promise(r=>setTimeout(r,1000));
      }
    }
  }
  onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }
  async announce(desc: RTCSessionDescriptionInit, to?: string): Promise {
    await fetch(`${this.baseUrl}/send`, { method:'POST', headers:{ 'content-type':'application/json' }, body: JSON.stringify({ kind:'desc', roomId:this.roomId, from:this.localId, to, payload: desc }) });
  }
  async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise {
    await fetch(`${this.baseUrl}/send`, { method:'POST', headers:{ 'content-type':'application/json' }, body: JSON.stringify({ kind:'ice', roomId:this.roomId, from:this.localId, to, payload: candidate }) });
  }
}

// 用法
const restSignaling = new RestPollingSignaling('alice','room-1','https://your-signal.example');
await restSignaling.register();
const multiP2PGame2 = new P2PGameLibrary({ signaling: restSignaling });
await multiP2PGame2.start();

WebSocket信令

示例中所用的参考实现;协议: { sys:'roster', roster:string[] } 为广播;通过 to 进行定向消息。

new WebSocketSignaling(localId: string, roomId: string, serverUrl: string)
await signaling.register();
signaling.onRoster((list) => {/* 更新 UI */});
signaling.onRemoteDescription((desc, from) => {/* 交给 PeerManager */});
signaling.onIceCandidate((cand, from) => {/* 交给 PeerManager */});

消息格式

// 客户端 → 服务器(注册)
{ roomId: string, from: string, announce: true, kind: 'register' }

// 服务器 → 客户端(名册广播)
{ sys: 'roster', roomId: string, roster: string[] }

// 客户端 → 服务器(SDP/ICE,定向或房间内广播)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

PeerManager(内部)

  • 为每个对端维护一个 RTCPeerConnection 与一个 RTCDataChannel,并连接必要的回调。
  • 在两个对端间,由字典序更小的 playerId 发起连接(创建 offer),另一方应答,以避免同时发起。
  • 发出 peerJoinpeerLeavehostChangeping 事件,并将解码后的网络消息转发为 netMessage
  • 背压:
    • off:通道可用则始终发送。
    • drop-moves:当 bufferedAmount 超过阈值时丢弃新的 move
    • coalesce-moves:用最新的 move 替换队列中较旧的。
  • 容量:强制执行 maxPlayers(不再初始化新连接;忽略多余 offer),并触发 maxCapacityReached(maxPlayers)

EventBus(内部)

class EventBus {
  on(name: N, fn: EventMap[N]): () => void
  off(name: N, fn: EventMap[N]): void
  emit(name: N, ...args: Parameters): void
}

Ping覆盖层

该覆盖层在页面上方渲染一个小仪表板,跟踪与每个连接对端的往返时延(RTT),并保留短历史(约 60 个采样)。 开发时可用它来发现抖动尖峰、验证是否走 TURN、比较各对端情况。

选项

{
  enabled?: boolean; // 默认 false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // 默认 'top-right'
  canvas?: HTMLCanvasElement | null; // 可提供自有 canvas,或让覆盖层自行创建
}

用法

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// 运行时开关
multiP2PGame.setPingOverlayEnabled(false);
阅读图表
每条彩色折线代表一个对端。低而平稳最好;“锯齿”或突增提示拥塞或经由中继(TURN)。若某个对端持续偏高, 可考虑调整角色分配(例如避免其成为主机)。

序列化

interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

function createSerializer(strategy: 'json'|'binary-min' = 'json'): Serializer

示例

权威主机应用意图

const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on("sharedPayload", (from, payload, channel) => {
  if (!isHost()) return;
  if (channel === "move-intent" && typeof payload === "object") {
    const p = payload as { pos:{x:number;y:number}; vel?:{x:number;y:number} };
    multiP2PGame.broadcastMove(multiP2PGame.getHostId()!, p.pos, p.vel);
  }
});

将临时载荷持久化到共享状态

multiP2PGame.on("sharedPayload", (from, payload, channel) => {
  if (channel !== "status") return;
  if (payload && typeof payload === "object" && "hp" in (payload as any)) {
    multiP2PGame.setStateAndBroadcast(multiP2PGame.getHostId()!, [
      { path: `objects.playerStatus.${from}`, value: { id:`playerStatus.${from}`, kind:"playerStatus", data:{ hp:(payload as any).hp } } }
    ]);
  }
});

选择性增量更新

const paths = multiP2PGame.setStateAndBroadcast(localId, [
  { path:"objects.chest.42", value:{ id:"chest.42", kind:"chest", data:{ opened:true } } }
]);
// paths == ["objects.chest.42"]

事件参考

playerMove

game.on('playerMove', (playerId, position) => {
    drawAvatar(playerId, position);
  });
  

inventoryUpdate

game.on('inventoryUpdate', (playerId, items) => {
    ui.updateInventory(playerId, items);
  });
  

objectTransfer

game.on('objectTransfer', (from, to, item) => {
    ui.toast(`${from} 将 ${item.id} 交给了 ${to}`);
  });
  

sharedPayload

game.on('sharedPayload', (from, payload, channel) => {
    if (channel === 'chat') chat.add(from, (payload as any).text);
  });
  

stateSync

game.on('stateSync', (state) => {
    world.hydrate(state);
  });
  

stateDelta

game.on('stateDelta', (delta) => {
    world.applyDelta(delta);
  });
  

peerJoin / peerLeave

game.on('peerJoin', (id) => ui.addPeer(id));
  game.on('peerLeave', (id) => ui.removePeer(id));
  

hostChange

game.on('hostChange', (hostId) => ui.setHost(hostId));
  

ping

game.on('ping', (id, ms) => ui.setPing(id, ms));
  

maxCapacityReached

game.on('maxCapacityReached', (max) => ui.alert(`房间已满 (${max})`));
  

生产注意事项

  • 准备 ICE(TURN)与安全信令(WSS)。
  • 为公平起见,可考虑可信/无头主机的权威模式。
  • 监控 RTCDataChannel.bufferedAmount 并调节背压。

重连与用户体验清单

  • 对端掉线时显示“正在重连”,依赖名册检测恢复。
  • 主机迁移后发送新的 state_full 以对齐客户端。
  • 可选启用 cleanupOnPeerLeave 在离开时清理状态(仅主机)。

调试

const game = new P2PGameLibrary({
  signaling,
  debug: {
    enabled: true,
    onSend(info){
      console.log('[send]', info.type, 'to', info.to, 'bytes=', info.payloadBytes, 'queued=', info.queued);
    }
  }
});
  

浏览器兼容性

  • 最新版 Chrome/Firefox/Edge/Safari 支持 DataChannel;Safari 生产环境需 HTTPS/WSS。
  • 企业/酒店网络建议部署 TURN;经中继时延会更高。

故障排除

WebRTC 连接建立失败

  • 混合内容:确保页面与信令都用 HTTPS/WSS(浏览器会阻止 HTTPS 页面上的 WS)。
  • 缺少 TURN:在企业/酒店网络下直连受限。于 iceServers 提供 TURN 凭证。
  • CORS/防火墙:信令端需要接受来源;检查反代规则与端口(TLS 443)。

DataChannel 堵塞(高延迟、输入滞后)

  • 背压:启用 coalesce-movesdrop-moves 并调节 thresholdBytes(从 256–512 KB 开始)。
  • 减小消息:优先增量;压缩载荷(binary‑min);向量量化(如 mm → cm)。
  • 降低发送率:限制移动广播(如 30–60Hz),依靠插值补帧。

主机切换后对端不同步

  • 确保新主机广播 state_full(库会自动触发)。
  • 客户端应应用全量快照并清理本地缓存(让插值稳定数帧)。

Safari 特定问题

  • 除 localhost 外需 HTTPS/WSS。
  • 检查 STUN/TURN URL 是否包含传输参数(如 ?transport=udp)。

游戏工作流

端到端模式:将网络、一致性与状态连接起来,适配不同游戏类型。

1) 实时竞技场(动作/射击)

  • 一致性:从 timestamp 开始;如有可信主机可切换至 authoritative
  • 同步:稳定阶段用增量;主机迁移时偶发全量。
  • 背压coalesce-moves 仅保留最新移动。
// 设置
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// 本地输入 → 广播移动(客户端预测由你的渲染器处理)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

multiP2PGame.on('playerMove', (id, pos) => renderPlayer(id, pos));

2) 合作 RPG(库存,主机应用意图)

  • 一致性authoritative(主机验证物品使用、门、宝箱)。
  • 协议:客户端发送意图(载荷);主机修改共享状态并广播增量。
// 客户端仅向主机发送意图
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// 主机处理意图并修改状态
const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on('sharedPayload', (from, payload, channel) => {
  if (!isHost() || channel !== 'intent') return;
  if ((payload as any).action === 'use-item') {
    const inv = getInventoryAfterUse(from, (payload as any).itemId);
    multiP2PGame.setStateAndBroadcast(localId, [ { path: `inventories.${from}`, value: inv } ]);
  }
});

3) 回合制战术(确定性,全量快照)

  • 一致性:权威主机负责规则与回合顺序。
  • 同步:每次移动广播小增量;每 N 回合发送全量以保底。
interface TurnMove { unitId:string; to:{x:number;y:number} }

multiP2PGame.on('sharedPayload', (from, payload, channel) => {
  if (channel !== 'turn-move' || multiP2PGame.getHostId() !== localId) return;
  const mv = payload as TurnMove;
  const ok = validateMove(currentState, from, mv);
  if (!ok) return; // 拒绝非法移动
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// 每 10 回合发送一次全量快照
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) 派对游戏 + 大厅(容量与迁移)

  • 设置 maxPlayers 保护用户体验;处理 maxCapacityReached 提示用户。
  • 利用名册展示大厅;离开时自动迁移主机。
const multiP2PGame = new P2PGameLibrary({ signaling, maxPlayers: 8 });

multiP2PGame.on('maxCapacityReached', (max) => showToast(`Room is full (${max})`));

multiP2PGame.on('hostChange', (host) => updateLobbyHost(host));

5) 开放世界沙盒(无边界,Z 轴)

  • 禁用世界边界;仅保留玩家对玩家碰撞。
  • 高频更新可使用 binary-min 以缩小载荷。
const multiP2PGame = new P2PGameLibrary({
  signaling,
  serialization: 'binary-min',
  movement: { ignoreWorldBounds: true, playerRadius: 20, smoothing: 0.25, extrapolationMs: 140 }
});

词汇表

  • SDP:会话描述协议;描述 WebRTC 使用的媒体/数据会话参数。
  • ICE:交互式连接建立;发现对端间的网络路径(经由 STUN/TURN)。
  • STUN:帮助客户端获知其公网地址的服务器;用于 NAT 穿透。
  • TURN:在无法直连时转发流量的中继服务器。
  • DataChannel:WebRTC 的双向数据通道,用于游戏消息。
  • LWW:最后写入胜;按发送方序列判定最新更新为准。