实战:构建 P2P 实时游戏 Demo

玩家位置同步、状态广播、延迟补偿——这三个词放在一起,其实就是 P2P 实时游戏最核心的骨架。我最早接触这个场景是在一个内部 hackathon 上,当时想做个双人射击小游戏,结果发现数据通道通了,但玩家位置一跳一跳的,根本没法玩。后来才明白,光会建通道远远不够,还得有一套同步策略。

这一章,我们就手把手搭一个完整的 P2P 游戏 Demo。我会把踩过的坑、试过的方案都摊开来讲。你跟着走一遍,基本就能应付大部分实时同步场景了。

整体架构:数据通道 + 游戏循环

先看整体逻辑。游戏 Demo 分为三个模块:

  • 连接层:基于 WebRTC 数据通道,负责收发消息
  • 同步层:处理位置、状态、延迟补偿
  • 渲染层:Canvas 绘制玩家、子弹、计分板

我个人习惯把同步层单独抽出来,不跟渲染混在一起。为什么呢?因为渲染帧率可能 60fps,但同步消息不需要那么高频。分开之后,调优更方便。

核心原则:数据通道只传状态增量,不传全量快照。全量快照只在玩家加入或重连时发送一次。

P2P 游戏 Demo 架构图 连接层 WebRTC DataChannel 同步层 位置/状态/延迟补偿 渲染层 Canvas 绘制 消息类型 • position: {x, y, rotation, timestamp} • state: {health, ammo, action} • ping: {seq, sendTime, recvTime} 延迟补偿策略 • 客户端预测 + 服务器(主机)校正 • 插值平滑:线性插值 / 球面插值

玩家位置同步:增量更新 + 时间戳

位置同步最忌讳的就是「全量刷」。你想想看,如果每帧都发 {x, y, rotation, velocity},带宽很快就炸了。我建议的做法是:

  • 只在位置变化超过阈值时发送
  • 带上本地时间戳,方便对方做插值
  • Uint8Array 编码,别用 JSON 字符串
// 位置消息编码(二进制)
function encodePosition(pos) {
  const buf = new ArrayBuffer(13); // 1 + 4 + 4 + 4
  const view = new DataView(buf);
  view.setUint8(0, 0x01); // 消息类型:位置
  view.setFloat32(1, pos.x, true);
  view.setFloat32(5, pos.y, true);
  view.setFloat32(9, pos.rotation, true);
  return buf;
}

// 发送条件:位移超过 0.5 像素 或 旋转超过 2 度
const THRESHOLD_DIST = 0.5;
const THRESHOLD_ANGLE = 2 * Math.PI / 180;

function shouldSendPosition(current, last) {
  const dx = current.x - last.x;
  const dy = current.y - last.y;
  const dist = Math.sqrt(dx*dx + dy*dy);
  const angleDiff = Math.abs(current.rotation - last.rotation);
  return dist > THRESHOLD_DIST || angleDiff > THRESHOLD_ANGLE;
}

小技巧:我曾经在项目里用 Float32Array 直接传,但发现不同字节序的设备会有问题。后来统一用 DataView 指定小端序,兼容性就好了。

状态广播:谁在做什么?

状态广播跟位置同步不太一样。位置是连续变化的,状态是离散的——比如开枪、换弹、受伤、死亡。这些事件需要「即时到达」,不能丢包。

我的做法是:

  • 状态变更时立即发送,不等待阈值
  • 每条状态消息带一个递增的序列号
  • 接收方检测序列号是否连续,如果发现跳号,主动请求重传
// 状态消息结构
const StateMessage = {
  type: 'state',
  seq: 0,          // 递增序列号
  playerId: '',
  health: 100,
  ammo: 30,
  action: 'idle',  // idle | shoot | reload | hit | dead
  timestamp: 0
};

// 发送状态变更
function broadcastState(newState) {
  stateSeq++;
  const msg = {
    ...newState,
    seq: stateSeq,
    timestamp: Date.now()
  };
  channel.send(JSON.stringify(msg)); // 状态消息小,JSON 够用
}

// 接收端检测丢包
let lastSeq = 0;
function onStateReceived(msg) {
  if (msg.seq !== lastSeq + 1) {
    console.warn(`丢包!期望 ${lastSeq + 1},收到 ${msg.seq}`);
    requestRetransmit(lastSeq + 1, msg.seq);
  }
  lastSeq = msg.seq;
  applyState(msg);
}

注意:状态广播不要用二进制编码。因为状态字段经常变,JSON 更灵活。位置消息才值得用二进制优化。

延迟补偿:让游戏「感觉」流畅

这是最头疼的部分。网络延迟是客观存在的,你没法消除它,只能「骗」过玩家。我常用的方案是:

  1. 客户端预测:本地操作立即生效,不等服务器确认
  2. 服务器校正:主机(或权威端)定期下发正确位置,客户端做插值修正
  3. 插值平滑:收到远端位置后,不要直接跳过去,而是用线性插值慢慢过渡
// 插值平滑实现
class Interpolator {
  constructor() {
    this.buffer = []; // 存储历史位置
    this.interpTime = 100; // 插值窗口 100ms
  }

  pushPosition(pos, timestamp) {
    this.buffer.push({ pos, timestamp });
    // 清理太旧的数据
    const cutoff = Date.now() - 200;
    this.buffer = this.buffer.filter(p => p.timestamp > cutoff);
  }

  getInterpolatedPosition(renderTime) {
    if (this.buffer.length < 2) return null;

    // 找到两个包围点
    const t = renderTime - this.interpTime;
    let p0 = this.buffer[0];
    let p1 = this.buffer[1];

    for (let i = 1; i < this.buffer.length; i++) {
      if (this.buffer[i].timestamp > t) {
        p0 = this.buffer[i - 1];
        p1 = this.buffer[i];
        break;
      }
    }

    // 线性插值
    const range = p1.timestamp - p0.timestamp;
    const factor = range > 0 ? (t - p0.timestamp) / range : 0;
    const clamped = Math.max(0, Math.min(1, factor));

    return {
      x: p0.pos.x + (p1.pos.x - p0.pos.x) * clamped,
      y: p0.pos.y + (p1.pos.y - p0.pos.y) * clamped,
      rotation: p0.pos.rotation + (p1.pos.rotation - p0.pos.rotation) * clamped
    };
  }
}

延迟补偿的核心:不是消除延迟,而是让延迟「不可感知」。插值窗口一般设为 50-150ms,太短会抖动,太长会感觉「肉」。我一般从 80ms 开始调。

实战代码整合:游戏循环

把上面所有东西串起来,就是一个完整的游戏循环。我习惯用 requestAnimationFrame 驱动渲染,用独立的定时器发送同步消息。

// 游戏主循环
class P2PGame {
  constructor(channel) {
    this.channel = channel;
    this.localPlayer = { x: 400, y: 300, rotation: 0, health: 100 };
    this.remotePlayers = new Map();
    this.interpolators = new Map();
    this.lastSentPos = null;

    // 每 50ms 检查并发送位置
    setInterval(() => this.syncPosition(), 50);
    // 每 1000ms 发送一次 ping
    setInterval(() => this.sendPing(), 1000);

    // 接收消息
    channel.onmessage = (e) => this.handleMessage(e.data);
  }

  syncPosition() {
    if (shouldSendPosition(this.localPlayer, this.lastSentPos)) {
      const buf = encodePosition(this.localPlayer);
      this.channel.send(buf);
      this.lastSentPos = { ...this.localPlayer };
    }
  }

  handleMessage(data) {
    // 根据消息类型分发
    if (data instanceof ArrayBuffer) {
      const type = new DataView(data).getUint8(0);
      if (type === 0x01) this.handlePosition(data);
    } else {
      const msg = JSON.parse(data);
      if (msg.type === 'state') this.handleState(msg);
      else if (msg.type === 'ping') this.handlePing(msg);
    }
  }

  // 渲染循环
  render(timestamp) {
    // 对每个远端玩家,获取插值后的位置
    for (const [id, interp] of this.interpolators) {
      const pos = interp.getInterpolatedPosition(timestamp);
      if (pos) {
        drawPlayer(id, pos.x, pos.y, pos.rotation);
      }
    }
    // 绘制本地玩家
    drawPlayer('local', this.localPlayer.x, this.localPlayer.y, this.localPlayer.rotation);
    requestAnimationFrame((t) => this.render(t));
  }
}

避坑指南

做 P2P 游戏同步,有几个坑我反复踩过:

  • 时间戳不同步:每个设备的 Date.now() 不一样。我后来统一用 performance.now() 配合 RTT 估算,效果好了很多。
  • 丢包导致位置跳变:如果连续丢包,插值器会缺数据。我加了一个「预测外推」逻辑——如果超过 200ms 没收到更新,就按上次速度继续移动。
  • 状态覆盖:如果状态消息乱序到达,后到的旧状态会覆盖新状态。序列号 + 丢弃旧消息可以解决。

我曾经在一个项目里忘了处理乱序,结果玩家死了之后又复活了——因为死亡消息先到,复活消息后到,但复活消息的序列号更小。排查了整整一个下午才找到原因。

总结

P2P 实时游戏的同步,说白了就是三件事:

  • 位置用增量 + 二进制 + 阈值发送
  • 状态用 JSON + 序列号 + 即时发送
  • 延迟用插值 + 预测 + 校正来掩盖

这套方案我用了好几个项目,从双人射击到多人赛车,基本都能跑得通。你照着这个 Demo 改一改,换成自己的游戏逻辑,很快就能看到效果。

公众号:蓝海资料掘金营,微信 deep3321