在德州扑克里加视频通话:原生 WebRTC + 复用信令服务器

在德州扑克里加视频通话:原生 WebRTC + 复用信令服务器

上一篇写了联机德州扑克的服务端架构。打牌的时候两个人没法面对面聊天,总觉得差点意思——于是给牌桌加了个视频通话:自己右下角、对手左上角各一个小浮窗,打开就”同桌”了。

实现比想象中简单,因为信令服务器早就有了(就是上一篇那个 PeerServer),视频通话只是它的”本职用法”。这篇文章记录完整方案:为什么不用 peerjs 库、信令如何与现有 Worker 打通、前端怎么写、以及测试中踩到的坑。

需求

  • 两人同房打牌时,可以可选开启视频通话(尊重隐私,默认关闭,点「📹 视频」按钮开启)
  • 自己的画面在右下角小窗,对手画面在左上角小窗,不挡牌桌
  • 复用已有 Worker,一行服务端代码都不改

整体架构

视频通话是两条独立的通道,互不干扰:

1
2
3
4
5
6
7
8
┌───────────────────────────────────────────────────────┐
│ 通道 1:扑克对局(已有) │
│ 浏览器 ──wss://…/poker?room──► PokerRoom DO(服务端权威)│
│ │
│ 通道 2:视频通话(本次新增,全在前端) │
│ 浏览器 A ◄──信令(WS)──► PeerServer DO ◄──信令(WS)──► 浏览器 B
│ └────────────── P2P 音视频直传 ──────────────┘
└───────────────────────────────────────────────────────┘

关键点:

  1. 信令只负责”牵线”:交换 SDP(offer/answer)和 ICE candidate,几 KB 的字符串。
  2. 音视频走 P2P 直传:不经过服务器,免费计划带宽不受影响。
  3. 两条通道完全独立:扑克断线不影响视频,反之亦然。

为什么不用 peerjs 库(重要)

peerjs 是最流行的 WebRTC 封装,上一篇部署 PeerServer 时也用它的客户端。但这次实测发现一个关键不兼容:

  • peerjs 1.5.4 客户端:连上 WS 后,等服务器主动发 {type:'OPEN'} 确认注册。
  • 我们的 PeerServer DO:等客户端主动发 {type:'OPEN', src:'<peerId>'} 来注册。

实测(Node 模拟 peerjs 客户端握手):WS 连上了,但服务器 5 秒不发任何消息——协议对不上,peer.on('open') 永远不触发。

教训:网上部署 PeerJS 信令服务器的教程很多,但不少是自定义简化协议,和标准 peerjs 客户端不完全兼容。用之前先实测握手流程,别直接信教程。

所以改用原生 WebRTC(RTCPeerConnection + getUserMedia),信令用裸 WebSocket 走现有协议。反而更简单:没有库依赖,协议完全可控。

信令协议(与现有 PeerServer 匹配)

复用现有 PeerServer DO 的协议,只有两条规则:

1
2
3
4
5
// 1. 注册:连上 WS 后发 OPEN
ws.send(JSON.stringify({ type: 'OPEN', src: 'poker-vc-<房间号>-<座位>' }));

// 2. 转发:任何带 dst 的 JSON 都会被服务器转发给对应 peerId
ws.send(JSON.stringify({ type: 'OFFER', dst: 'poker-vc-<房间号>-<对方座位>', sdp: '...' }));

peerId 派生规则:poker-vc-<房间号>-<座位>,座位来自扑克 welcome 消息(0 或 1)。这样同房必可达、全局唯一、跨房天然隔离。

消息类型就三种:OFFER(携带 SDP)、ANSWER(携带 SDP)、ICE(携带 candidate),外加系统级的 OPEN。

前端实现

1. 建立连接:getUserMedia + 信令

1
2
3
4
5
6
7
8
// 就座后(可选)开启视频
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(function (stream) {
vc.localStream = stream;
myVideo.srcObject = stream; // 本地小窗
vcEnsurePC(); // 创建 RTCPeerConnection
vcSignaling(); // 连信令 WS 并注册 peerId
});

2. 谁先发起 offer

谁先连上信令谁就”有责任”发起。约定座位 1 优先发起,座位 0 兜底:

1
2
3
4
5
6
7
8
9
// 座位 1:信令就绪后主动发起
if (mySeat === 1) vcMakeOffer();

// 座位 0 兜底:4 秒没等到 offer 就自己发起
if (mySeat === 0) {
setTimeout(function () {
if (vc.pc && !vc.connected && !vc.receivedOffer) vcMakeOffer();
}, 4000);
}

为什么需要兜底:座位 1 发 offer 时如果座位 0 还没注册信令,服务器会静默丢弃这条消息。兜底逻辑让连接总能建立。

3. ICE 缓冲(容易漏的细节)

信令是异步的,offer/answer 可能晚于 ICE candidate 到达。如果直接 addIceCandidate 会报错(没有 remoteDescription)。正确做法是缓冲:

1
2
3
4
5
6
7
8
function vcOnIce(candidate) {
var pc = vcEnsurePC();
if (pc.remoteDescription) {
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(function () {});
} else {
vc.iceBuf.push(candidate); // 等 remoteDescription 就绪再补投
}
}

4. 视频浮窗布局(不挡牌桌)

1
2
3
4
5
6
7
8
┌──────────────────────────────────┐
│ [对手视频] 公共牌/奖池(中央) │
│ left:14px │ │
│ top:60px │ │
│ │ [自己视频] │
│ │ right:14px │
│ │ bottom:14px │
└──────────────────────────────────┘
1
2
3
4
5
6
7
.pk-vc { position: fixed; z-index: 40; border-radius: 12px; overflow: hidden; }
.pk-vc.pk-vc-me { right: 14px; bottom: 14px; width: 200px; height: 140px; }
.pk-vc.pk-vc-opp { left: 14px; top: 60px; width: 200px; height: 140px; }
@media (max-width: 620px) { /* 移动端缩小 */
.pk-vc.pk-vc-me { right: 8px; bottom: 8px; width: 130px; height: 96px; }
.pk-vc.pk-vc-opp { left: 8px; top: 56px; width: 130px; height: 96px; }
}

5. 开关(隐私优先)

默认不开启,玩家点「📹 视频」按钮才请求摄像头:

1
2
3
4
vcBtn.addEventListener('click', function () {
if (!vc.active) vcInit(); // 开启:getUserMedia + 信令
else vcTeardown(); // 关闭:释放全部资源
});

6. 生命周期管理(最容易出 bug 的部分)

关闭/离开时必须完整释放,否则摄像头红灯常亮、声音残留:

1
2
3
4
5
6
7
8
9
function vcTeardown() {
if (vc.pc) vc.pc.close(); // 1. 关 RTCPeerConnection
if (vc.ws) vc.ws.close(); // 2. 关信令 WS
if (vc.localStream) {
vc.localStream.getTracks().forEach(t => t.stop()); // 3. 停掉摄像头/麦克风轨道
}
myVideo.srcObject = null; // 4. 清 DOM 引用
}
window.addEventListener('beforeunload', vcTeardown); // 页面关闭时兜底

踩过的坑

坑 1:localDescription 是对象不是字符串

第一版测试直接发 vc.pc.localDescription(对象),JSON 序列化后 sdp 字段变成嵌套对象,对端 new RTCSessionDescription({sdp: {...}}) 解析直接报错:

1
Failed to parse SessionDescription. [object Object] Expect line: v=

修复:发送时取 .sdp 字符串:

1
2
3
4
5
// ❌ localDescription 是对象
vcSend({ type: 'OFFER', sdp: vc.pc.localDescription });

// ✅ 取 .sdp 字符串
vcSend({ type: 'OFFER', sdp: vc.pc.localDescription.sdp });

坑 2:peerjs 库与自定义信令协议不兼容

见上文「为什么不用 peerjs 库」。任何”信令服务器”教程都要先实测握手。

坑 3:NAT 穿透失败时的 TURN

多数场景 stun:stun.l.google.com 就够了;两家运营商互连失败时(connectionState 卡在 connecting 超时)需要 TURN。Cloudflare 提供免费 TURN(turn.cloudflare.com),加到 iceServers 即可,不用自己部署。

测试验证

分两层测:

  1. 信令层(Node 模拟):两个客户端连 PeerServer DO,验证注册、双向转发、跨房间隔离。结果全通过。
  2. 媒体层(Chrome headless 双窗口 + 假摄像头):模拟两个真实浏览器建立 P2P。结果:双方都收到远端视频流,connectionState 变为 connected。

生产页面的开关交互(点按钮开启、再点关闭)逻辑与测试页同构,用户在真实浏览器双窗口确认即可。

经验总结

  1. 信令服务器是内容无关的:传扑克数据也好、传 SDP/ICE 也好,它只负责转发。已有信令 = 视频通话的地基,别再部署一套。
  2. 第三方库的协议假设要实测:peerjs 教程满天飞,但你的服务器协议可能和它假设的不一致。测一次握手就知道,别写完才踩。
  3. WebRTC 的异步细节:ICE 缓冲、.sdp 字符串,这两个小坑排查起来最隐蔽。
  4. 视频通话的本质是 P2P:信令只牵线,音视频直传。服务器零带宽压力,免费计划毫无压力。

在德州扑克里加视频通话:原生 WebRTC + 复用信令服务器
https://neoisconstantine-github-io.pages.dev/2026/08/12/在德州扑克里加视频通话/
作者
constantine
发布于
2026年8月13日
许可协议