基于《灵魂契约》TCG 卡牌游戏联机功能的开发文档分享
> 一份关于卡牌/棋类/回合制游戏 P2P 联机架构的实现总结 > 版本: v2.1.2-FINAL-27.270b > 适用场景: 任何需要双人/多人对战的卡牌/棋类/回合制游戏
局域网联机架构文档
《灵魂契约》TCG 卡牌联机功能文档 作者:Vison
一份关于卡牌/棋类/回合制游戏 P2P 联机架构的实现总结 版本: v2.1.2-FINAL-27.270b 适用场景: 任何需要双人/多人对战的卡牌/棋类/回合制游戏
📝 文档信息
| 项目 | 内容 |
|---|---|
| 文档名称 | 局域网联机架构文档 |
| 作者 | Vison |
| 版本 | v2.1.2-FINAL-27.270b |
| 创建日期 | 2026-08-29 |
| 项目 | 灵魂契约 (Soul Contract) |
| 文档类型 | 联机架构技术文档 |
| 适用读者 | TCG/棋类/回合制游戏开发者 |
📜 版权声明
本文档由 Vison 原创编写, 记录了 P2P 联机功能的通用架构模式与工程实践。
本文档的目的是分享技术经验, 帮助其他游戏开发者理解并实现类似的 P2P 联机功能。文档不包含任何具体游戏机制、卡牌数据、平衡数值、商业逻辑等专有内容。如需参考本文档的设计思路, 请保留对原作者的致谢。
本文档可自由用于学习和参考, 但请勿用于商业用途的转售或大规模转载。
📑 目录
- 架构概览
- 为什么选择这个架构
- 核心设计原则
- 代码组织
- 连接层:短码 P2P
- 数据模型:固定玩家视角分配
- 同步协议:双通道设计
- 事件流:实时动画同步
- 常见坑和解决方案
- 可复用的设计模式清单
- 本架构对比传统 P2P‑Host 的优化点
📋 版本演进记录
| 版本 | 日期 | 变更说明 |
|---|---|---|
| v2.1.2‑FINAL‑27.270b | 2026‑08‑29 | 完整定稿本架构;引入双方客户端本地校验;彻底移除视角交换机制;双通道同步协议;独立战斗事件流;实体稳定标识跟踪;修复全部已知工程坑;新增架构对比、可迁移服务端权威说明。 |
1. 架构概览
┌─────────────────────────┐ ┌─────────────────────────┐
│ Host (P1) │ P2P │ Client (P2) │
│ ┌─────────────────┐ │ ◄────► │ ┌─────────────────┐ │
│ │ Game Logic │ │ │ │ Game Logic │ │
│ │ (authoritative)│ │ │ │ (validates) │ │
│ └─────────────────┘ │ │ └─────────────────┘ │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ State │ │ │ │ State │ │
│ │ local = P1 │ │ │ │ local = P2 │ │
│ │ remote = P2 │ │ │ │ remote = P1 │ │
│ └─────────────────┘ │ │ └─────────────────┘ │
│ ┌─────────────────┐ │ sync │ ┌─────────────────┐ │
│ │ Sync Layer │ ───┼─────────┼──│ Sync Layer │ │
│ │ (orchestrator) │ │ │ │ (follower) │ │
│ └─────────────────┘ │ │ └─────────────────┘ │
└─────────────────────────┘ └─────────────────────────┘
核心一句话:
- P1 是真理之源 (authoritative host)
- P2 是镜像 (client receives state, sends intents)
- local / remote 视角固定分配 (P1 视角下永远
local=P1, remote=P2)
2. 为什么选择这个架构
2.1 候选方案对比
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 专用服务器 | 权威、易调试、客户端轻 | 需服务器、运维成本高 | 商业产品 |
| P2P + 主机权威 | 零运维、低延迟、易开发 | 主机掉线=对局结束 | 小团队/独立游戏 ✅ |
| 纯 P2P(无主机) | 无单点故障 | 同步复杂、容易脏数据 | 大型多人 |
我们的选择: P2P + 主机权威
- 双人卡牌游戏不需要专用服务器
- 主机掉线概率低(用户主动结束对局)
- 开发简单:只有一份"权威"逻辑
2.2 关键权衡
| 决策 | 选择 | 理由 |
|---|---|---|
| 网络协议 | WebRTC (PeerJS) | 免运维、自动穿透 NAT |
| 房间标识 | 4-6 字符短码 | 易分享、足够唯一 |
| 同步粒度 | 完整 state 广播 | 卡牌游戏数据量小,简单 |
| 战斗动画 | 事件流 (push) | 实时性、客户端不重算 |
| 状态机 | 单 host 推动 | 避免分布式状态机问题 |
3. 核心设计原则
原则 1: P1 永远是真理之源
P1 处理所有 P2 的意图 → 本地执行 → 广播结果
P2 验证意图合法性 → 发送意图 → 等待结果
好处:
- 只有一份"权威"游戏逻辑
- 不会出现 P1 和 P2 状态不一致
- 调试简单:所有 bug 都在 P1 上
原则 2: 双边本地验证
旧设计: P2 只发 intent,P1 验证一切 问题: P1 验证会引入"两边规则不一致"风险
新设计: P2 也要本地验证
// P2 端 (client) - 本地先验证
function onPlayerAction(idx, slot) {
if (!canPerformAction()) return; // 本地规则校验
Sync.sendIntent({ idx, slot, ... });
}
// P1 端 (host) - 防御性检查 + 执行
function _handleIntent(data) {
// P1 是权威源, 不需要再重复全部业务校验
Game.executeAction(data, 'remote');
}
好处:
- P2 给玩家即时反馈
- P1 不需要重复实现验证逻辑
- 双边规则一致
原则 3: local / remote 视角固定分配
重要 ❗❗❗
// P1 视角
State.local = P1_data; // 我
State.remote = P2_data; // 对手
// P2 视角
State.local = P2_data; // 我
State.remote = P1_data; // 对手
反例 (我们曾经犯过的错):
// ❌ P1 视角下 State.remote = P2_data, 但尝试做"镜像"
// 假设 State.local 永远是"我", 需要 swap 才能让逻辑作用于 State.remote
function runAsRemote(fn) {
const saved = State.local;
State.local = State.remote; // swap!
try { fn(); } finally {
State.local = saved; // 还原
}
}
// 问题: swap 是临时状态修改, 有数据污染风险
正确做法:
// ✅ 函数直接接受 who 参数, 不做 swap
function executeAction(idx, slot, who = 'local') {
const target = (who === 'local') ? State.local : State.remote;
// ... 操作 target 而不是 State.local
}
// 调用
Game.executeAction(idx, slot, 'remote'); // 操作 P2
好处:
- 零状态污染风险 (没有临时 swap)
- 代码可读 (一看
who就知道操作谁) - bug 不会跨 sync (P1 的 state 永远是干净的)
原则 4: 事件流用 push, 状态用 pull
事件流 (push): P1 实时推给 P2 (伤害数字、死亡动画、攻击特效)
状态 (pull): P1 周期性推完整 state (回合切换、HP 变化)
事件流为什么 push:
- 客户端不重算(性能好)
- 动画实时(不卡顿)
- 顺序明确(按时间戳播放)
状态为什么 pull:
- 简单可靠(直接覆盖)
- 不需要复杂的冲突解决
4. 代码组织
game_demo_experiment_v2.1.2/
├── js/
│ ├── peer-pvp.js # 连接层 (P2P 封装)
│ ├── pvp-sync.js # 同步逻辑 (状态广播、意图处理)
│ ├── main.js # 游戏逻辑 (game rules, with who parameter)
│ ├── state.js # 状态模型 (State.local / State.remote)
│ ├── battle.js # 战斗系统 (动画、伤害计算)
│ └── ...
4.1 各文件职责
| 文件 | 职责 | 关键函数 |
|---|---|---|
peer-pvp.js | P2P 连接 | host(shortCode), join(shortCode), send(data), onMessage(callback) |
pvp-sync.js | 同步协议 | intentPlayCard(), _hostHandlePlayCardIntent(), _hostBroadcastSync() |
main.js | 游戏规则 | executeAction(who), endRound() |
state.js | 状态定义 | State.local, State.remote, State.game |
4.2 调用流向
[玩家点击] [P1 收到] [P2 收到]
ui.js (本地)
→ main.js executeAction('local') ──── 不直接调, 走 Sync
[Sync enabled?]
↓ 是
Sync.sendIntent(idx, slot, ...)
↓ 发送
peer-pvp.send({ type: 'intent_...', ... })
↓ P2P
P2 收到
↓
P2 端 Sync.handleMessage()
↓ 调用本地逻辑 (本地验证)
(P2 不会执行, 只发 intent)
[P1 处理 intent] [P1 广播] [P2 接收]
Sync._hostHandlePlayCardIntent() ──── → peer-pvp.send({ type: 'sync', state, events })
→ Game.executeAction('remote') ↓
→ 触发战斗事件 P2 端 Sync._clientApplySync()
↓
1. 应用 state (UI 渲染)
2. 播放 events (动画)
5. 连接层:短码 P2P
5.1 短码系统
// peer-pvp.js
host(shortCode) {
this._peer = new Peer(shortCode, { ... });
this._peer.on('open', (id) => console.log('主机创建, 短码:', id));
this._peer.on('connection', (conn) => {
this._conn = conn;
this._conn.on('data', (data) => this._onMessage(data));
});
}
短码选择:
- 4-6 个字符 (如
nrxvx) - 大写字母 + 数字 (去歧义字符: 0/O, 1/I/L)
- 短码冲突时自动重试
5.2 信令服务器
// 公共 broker (PeerJS 提供)
new Peer(shortCode, {
host: '0.peerjs.com',
port: 443,
secure: true,
path: '/'
});
// 或自建 (peer-server.js)
new Peer(shortCode, {
host: 'localhost',
port: 9000,
path: '/peerjs',
config: { ... }
});
为什么需要信令:
- WebRTC 需要交换 SDP 才能建立直连
- 信令服务器只负责"介绍"双方
- 建立直连后, 游戏数据不经过服务器
5.3 消息收发
// peer-pvp.js
send(data) {
if (!this._conn) {
console.error('[PeerPVP] 连接未建立');
return;
}
this._conn.send(data);
}
onMessage(callback) {
this._onMessage = callback;
}
注意: PeerJS 的 send 是异步的, 不保证立即送达
- 短消息 (intent) 几乎即时
- 大消息 (state 同步) 可能有几百毫秒延迟
- 重要数据需要在收到确认后再发送 (我们用 sync + events 分离避免这个问题)
6. 数据模型:固定玩家视角分配
6.1 State 结构 (通用模板)
const State = {
game: {
turn: 1,
isLocalTurn: true, // 通用字段, PVE/PVP 都用
phase: 'battle', // menu / deck / battle / ended
actionPhase: true, // true=可行动, false=战斗中
currentTurn: 1, // PVP 用, 1=P1, 2=P2
whoseTurn: 'host', // PVP 用, host/client
winner: null,
},
local: { // 当前用户的视角
deck: [],
hand: [],
battle: [null, null, null, null, null],
discard: [],
soul: 5,
health: 50,
// ... 其他游戏特有字段
},
remote: { // 对方
// 同样的结构
}
};
注: 以上只是通用结构示例, 实际游戏的具体字段、状态类型、长度由具体游戏决定, 文档不涉及具体游戏的字段设计。
6.2 关键约定
| 规则 | 说明 |
|---|---|
State.local 是"我" | PVE 中永远是人, PVP 中 P1 看是自己, P2 看是自己 |
State.remote 是"对方" | 同上, 镜像的另一边 |
| P1 视角 | local=P1, remote=P2 |
| P2 视角 | local=P2, remote=P1 |
| 数据不共享 | 同一份数据, 不同视角 (服务端是 P1, 客户端 P2 收到的是 P1 的数据) |
6.3 错误示范: swap
// ❌ 错误: 用 swap 临时让 State.local 指向对方
function runAsRemote(fn) {
const saved = State.local;
State.local = State.remote; // swap!
try { fn(); } finally {
State.local = saved;
}
}
// 风险: 如果 swap 还原失败, 全局状态被污染
6.4 正确示范: who 参数
// ✅ 正确: 函数接受 who 参数, 不做 swap
function executeAction(idx, slot, expectedId, who = 'local') {
const target = (who === 'local') ? State.local : State.remote;
const other = (who === 'local') ? State.remote : State.local;
if (!target || !other) return;
// 用 target 替代 State.local
// 用 other 替代 State.remote
}
7. 同步协议:双通道设计
7.1 双通道设计
| 通道 | 方向 | 触发 | 内容 | 时机 |
|---|---|---|---|---|
| Intent | P2 → P1 | 玩家操作 | 想做什么 (idx, slot, id) | 实时 |
| Sync | P1 → P2 | P1 状态变化 | state + events | 异步 |
7.2 Intent 协议
// P2 端
function sendPlayCardIntent(handIndex, slot, cardId) {
PeerPVP.send({
type: 'intent_play_card',
handIndex: handIndex,
slot: slot,
expectedCardId: cardId, // 用于 handIndex 错位时纠正
});
}
// P1 端
function _hostHandlePlayCardIntent(data) {
// 1. 验证 intent 合法性 (防御性检查)
if (!remote.hand[data.handIndex]) return;
// 2. 执行
Game.executeAction(data.idx, data.slot, data.expectedId, 'remote');
// 3. 广播结果
this._hostQueueSync(events, 'play_card');
}
Intent 类型清单 (通用示例):
intent_play_card- 出牌 (具体动作名由游戏决定)intent_action_a- 动作 Aintent_action_b- 动作 Bintent_end_turn- 结束回合
注: 实际 Intent 类型的具体设计由游戏业务决定。
7.3 Sync 协议
// P1 端 (host)
_hostBroadcastSync(events, reason) {
PeerPVP.send({
type: 'sync',
reason: reason, // 调试用
state: { // 完整状态
host: this._serializePlayer(State.local),
client: this._serializePlayer(State.remote),
game: { turn, phase, actionPhase, whoseTurn, winner }
},
events: events, // 战斗事件流 (见第 8 节)
});
}
// P2 端 (client)
_clientApplySync(packet) {
// 1. 应用 state
this._applyPlayer(State.remote, packet.state.host); // P1 → State.remote
this._applyPlayer(State.local, packet.state.client); // P2 → State.local
// 2. 播放 events (动画)
for (const event of packet.events) {
this._playEvent(event);
}
}
为什么 P2 的 state 要"翻转"?
- P1 发送
state.host(P1 的数据) 和state.client(P2 的数据) - P2 收到后:
state.host是 P1 的数据 → 放在自己的State.remote state.client是 P2 的数据 → 放在自己的State.local- 这样 P2 看到的是 "自己的手牌在 State.local, 对手的手牌在 State.remote"
7.4 Sync 序列化
// P1 端
function serializePlayer(p) {
return {
health: p.health,
soul: p.soul,
hand: p.hand.map(serEntity), // 浅拷贝 + 深拷贝嵌套对象
battle: p.battle.map(serEntity),
deckCount: p.deck.length, // 不发牌库内容 (太大)
discardCount: p.discard.length,
// ... 其他游戏特有字段
};
}
function serEntity(c) {
// 给每个实体生成稳定唯一 uid (用于跨 sync 跟踪)
if (!c.uid) c.uid = c.id + '_' + Math.random().toString(36);
return {
id, name, dead, uid,
// ... 实体其他字段
};
}
重要: 必须深拷贝嵌套对象 (任何包含对象/数组的字段), 否则客机修改会污染主机
8. 事件流:实时动画同步
8.1 为什么需要事件流
战斗是实时的:
- 伤害数字要立即弹出
- 死亡动画要立即播放
- 攻击特效要立即触发
如果用 sync 拉数据:
- P1 战斗完才发 sync
- P2 看到一整回合的伤害一起出现
- 体验差
8.2 事件流设计
// P1 端 (battle.js)
function fight(attacker, defender) {
// ... 伤害计算 ...
EventBus.emit(TIMING.PVP_FIGHT_START, { attacker, defender });
// ... 伤害处理 ...
EventBus.emit(TIMING.PVP_DAMAGE_DEALT, { who, slot, amount });
if (defender.dead) {
EventBus.emit(TIMING.PVP_ENTITY_DISSOLVING, { who, slot, id });
}
}
// pvp-sync.js: 订阅事件, 转发给 P2
this._pvpEventHandlers.damage = (data) => {
self._hostFlushNow([{ type: 'pvp_damage_dealt', data, t: 0 }], 'pvp_damage_dealt');
};
EventBus.on(TIMING.PVP_DAMAGE_DEALT, this._pvpEventHandlers.damage);
8.3 事件类型清单 (通用)
PVP_FIGHT_START → 战斗开始
PVP_DAMAGE_DEALT → 伤害造成
PVP_ENTITY_DISSOLVING → 实体死亡 (溶解动画)
PVP_SUMMON_EFFECT → 召唤特效
PVP_ATTACK_EFFECT → 攻击特效
PVP_FIGHT_END → 战斗结束
注: 实际事件类型和触发条件由游戏具体设计决定。
8.4 P2 端播放事件
// P2 端
function _playEvent(event) {
switch (event.type) {
case 'pvp_fight_start':
Battle.showFightAnimation(event.data.attacker, event.data.defender);
break;
case 'pvp_damage_dealt':
Battle.showDamageNumber(event.data.who, event.data.slot, event.data.amount);
break;
case 'pvp_entity_dissolving':
Battle.playDissolveAnimation(event.data.who, event.data.slot);
break;
// ...
}
}
8.5 关键: 事件立即发送, 不缓存
// ✅ 立即发送 (每个事件单独发, t=0 让客机立刻播)
function pushEvent(type, data) {
self._hostFlushNow([{ type, data, t: 0 }], type);
}
// ❌ 错误: 缓存到 fight_end 才发 (导致 P2 看到 2.1s 延迟)
// 旧代码: events.push({ type, data, t: offset });
9. 常见坑和解决方案
坑 1: isLocalTurn 检查没对齐 who
症状: P2 出不了牌
原因: 函数顶部 if (!State.game.isLocalTurn) return; 在主机处理客机意图时被挡
修复:
// ❌ 错的
if (!State.game.isLocalTurn) return;
// ✅ 对的
const expectedTurn = (who === 'local');
if (State.game.isLocalTurn !== expectedTurn) return;
经验: 任何"是否轮到当前玩家"的检查, 都要考虑 who 参数
坑 2: 浅拷贝导致客机修改污染主机
症状: P2 改变实体数据, P1 也变了 (或反之)
原因: _applyPlayer 只浅拷贝实体, 嵌套对象共享引用
修复:
// ✅ 深拷贝嵌套对象
function serEntity(c) {
if (!c) return null;
var copy = Object.assign({}, c);
if (copy.modifierA) copy.modifierA = JSON.parse(JSON.stringify(copy.modifierA));
if (copy.modifierB) copy.modifierB = JSON.parse(JSON.stringify(copy.modifierB));
// ... 其他嵌套对象
return copy;
}
经验: 同步状态时, 任何嵌套对象都要深拷贝
坑 3: 事件缓冲导致动画延迟 2.1s
症状: P2 看到战斗动画比 P1 慢 2 秒
原因: 旧代码把伤害/死亡事件 push 到 buffer, 等 pvp_fight_end 才一起发
修复:
// ✅ 立即发送
function pushEvent(type, data) {
self._hostFlushNow([{ type, data, t: 0 }], type);
}
经验: 战斗事件必须立即发送, 不能缓冲
坑 4: 元素被 16ms 替换导致动画丢失
症状: P2 看到攻击动画瞬间消失
原因: P2 收到 sync 后, UI.update() 重建 DOM, 16ms 后替换了正在播放动画的元素
修复:
// ✅ 先发动画, 再 UI 渲染
function _clientApplySync(packet) {
this._applyState(packet.state); // 1. 更新 state (还没渲染)
this._scheduleUIUpdate(); // 2. 安排 UI 更新 (rAF 异步)
requestAnimationFrame(() => { // 3. 先发动画
for (const event of packet.events) {
this._playEvent(event);
}
});
}
经验: 收到 sync 时, 动画优先级 > UI 渲染
坑 5: 同步后元素查不到
症状: P2 战斗动画报错 "attEl=null"
原因: UI.update() 还没渲染, _playEvent 就查元素
修复:
// ✅ 等待下一帧再播
function _playEvent(event) {
requestAnimationFrame(() => {
const el = document.querySelector(`...`);
if (!el) return; // 元素可能还没渲染
el.animate(...);
});
}
坑 6: 攻击者用对象引用对比, 同步后失效
症状: 某些组合效果反复触发
原因: 组合代码用 new Set(entities), 同步后是 Object.assign({}, c) 新对象, 引用不同
修复:
// ✅ 用稳定 key
const getKey = c => c.uid || (c.id + '_' + c.tier);
const setA = new Set(oldSet.map(getKey));
const setB = new Set(newSet.map(getKey));
if (!setsEqual(setA, setB)) { ... }
经验: 任何"集合"比较, 都要用稳定 key (uid) 而不是对象引用
坑 7: 多个攻击者被限制不能打同一目标
症状: P1 的多个攻击者排队后, 第一个打了某个目标, 后面都去打玩家
原因: 旧代码用 usedTarget 限制每个目标只能被打一次
修复:
// ✅ 移除 usedTarget 限制
// 多个攻击者可以打同一目标
坑 8: 回合切换不重置攻击标记
症状: 某些单位每回合都标记已攻击, 第二回合永远不能攻击
原因: PVP 不走 endRound, 所以没有重置
修复:
// ✅ 在 _hostStartHostTurn/ClientTurn 中手动重置
for (let i = 0; i < 5; i++) {
if (State.local.battle[i]) State.local.battle[i].attackedThisTurn = false;
if (State.remote.battle[i]) State.remote.battle[i].attackedThisTurn = false;
}
坑 9: 索引错位导致动作失败
症状: P2 想执行某个动作, 主机收到索引但 P2 状态已经变了
原因: P2 发送 intent 后, P2 状态可能因为其他事件变了
修复:
// ✅ 用 expectedId + findIndex 纠正
if (expectedId && target.hand) {
const entity = target.hand[handIndex];
if (!entity || entity.id !== expectedId) {
const found = target.hand.findIndex(c => c && c.id === expectedId);
if (found >= 0) handIndex = found;
}
}
坑 10: swap 状态污染
症状: P1 处理 P2 intent 时, 中途崩溃, swap 没还原, 全局 state 错乱
原因: runAsRemote 用 try/finally 还原, 但如果有 listener 抛异常, finally 还能跑, 但如果 process 中途异步出错...
修复: 改用 who 参数, 彻底消除 swap
10. 可复用的设计模式清单
模式 1: 权威主机 + 双边验证
P1 处理一切, P2 验证后发 intent
- 简单可靠
- bug 集中在 P1
- P2 玩家体验好 (即时反馈)
模式 2: local/remote 固定分配 + who 参数
不要 swap, 用参数
- 零状态污染风险
- 代码可读
- 易于测试
模式 3: Intent + Sync 双通道
Intent 走"做什么" (轻量、实时)
Sync 走"结果" (重量、可靠)
- 各司其职
- 不需要复杂的 RPC
模式 4: 事件流 (push) + 状态 (pull)
事件 push 实时动画
状态 pull 完整覆盖
- 动画流畅
- 状态可靠
模式 5: 完整 state 广播
不发送 diff, 直接发完整 state
- 数据量小 (卡牌游戏状态 < 10KB)
- 不需要冲突解决
- 实现简单
模式 6: 稳定 uid 跨 sync 跟踪
每个实体生成唯一 uid, 不依赖对象引用
- 跨 sync 比较稳定
- 组合触发正确
- 易于调试
模式 7: 防御性验证 + 主方不重复
客机验证 + 主机不验证
- 客机快速反馈
- 主机不重复
- 双边规则一致
模式 8: 同步后异步 UI 渲染
收到 sync → 立即播事件 → rAF 后 UI 渲染
- 动画不被打断
- 流畅体验
11. 本架构对比传统 P2P‑Host 的优化点
传统P2P‑Host通用特征:主机作为全局真理源;公网环境下主机客户端掌握全部游戏逻辑,主机作弊可直接伪造对局结果;线上线下复用同一套逻辑;缺少双方校验;经常使用状态交换切换玩家视角,容易产生状态污染。
本项目没有推翻P2P‑Host主机权威模型本身,而是在数据模型、校验机制、同步协议、风险边界上做针对性改良。
11.1 数据模型层面:摒弃视角交换,使用固定视角分配 + who 入参
- 传统做法:大量实现会使用临时交换函数切换玩家的视角字段,操作对手数据,异常抛出时容易造成全局状态污染。
- 本架构优化:local / remote 视角字段固定,所有游戏逻辑函数增加
who入参,直接指定操作目标。完全移除视角交换机制,从根源消除状态污染风险。 - 收益:逻辑代码可复用、调试简单,同步过程不会因为异常导致全局 state 错乱。
11.2 校验机制:双方客户端本地验证
- 传统P2P‑Host:仅主机单方面做输入校验;客户端只负责发送操作意图。容易出现客户端本地规则与主机规则不一致,产生诡异的对局行为。
- 本架构优化:客机先本地完成全部规则校验,校验通过才发送 Intent;主机做防御性检查直接执行逻辑,不再重复全套业务校验。
- 收益:客户端即时 UI 反馈;保证双方规则逻辑同源,减少"客户端允许、主机拒绝"这类不同步 bug。
11.3 同步协议分层:双通道,事件 push、状态 pull 分离
- 传统P2P‑Host常见实现:要么只传完整状态快照,动画卡顿;要么只传增量指令,状态漂移后难以修复。事件经常缓冲等到回合结束批量发送,动画延迟严重。
- 本架构优化:
- Intent 通道:客户端发送操作意图,轻量实时;
- Sync 通道:主机广播完整序列化状态做兜底;
- 战斗事件独立 push 推送,不做缓存积压;完整 State 做 pull 兜底覆盖,不做复杂 diff 增量。
- 收益:战斗动画实时流畅;完整 state 兜底,状态漂移时可以自动修复;实现复杂度可控,适合卡牌回合制。
11.4 对象引用风险:实体稳定 uid + 嵌套对象深拷贝序列化
- 传统P2P‑Host坑点:直接传递对象引用,客机修改嵌套对象会污染主机内存;比较集合依赖对象引用,同步之后引用断裂导致组合、buff 逻辑异常。
- 本架构优化:每个实体生成全局稳定
uid标识;序列化时对任何嵌套对象强制深拷贝;集合对比全部基于 uid 字符串 key,不依赖 JS 对象引用。 - 收益:规避跨同步周期对象失效、状态互相篡改污染问题。
11.5 UI 渲染时序控制:事件优先,异步 UI 更新
- 传统P2P‑Host:收到同步数据包直接覆盖状态、立刻刷新 UI,DOM 重建销毁正在播放的动画,造成动画丢失、播放报错。
- 本架构优化:收到 Sync 包,先更新数据状态,优先播放战斗事件动画,再通过
requestAnimationFrame调度 UI 刷新。 - 收益:保证动画播放生命周期,避免 DOM 重建打断特效。
11.6 架构定位说明
本架构并不是创造出一类全新的基础网络架构范式。 底层依旧建立在主机权威 P2P‑Host模型之上,属于针对双人 TCG、回合棋类场景的工程改良变体。 传统 P2P‑Host 只定义顶层工作流程,但缺少工程层面成套的避坑方案。 本架构的创新点在于:将"废弃视角交换、who 参数模型、双方客户端本地校验、双通道同步协议、事件/状态分离、实体 uid 跟踪、UI 动画时序控制"等手段组合为一套完整可落地的实现范式,解决传统 P2P‑Host 在卡牌游戏里高频遇到的状态污染、规则不一致、动画丢失、对象引用失效等工程问题。
与商业产品架构对比
-
商业 TCG(服务端权威):所有卡牌连锁、触发逻辑全部在官方中央服务器运算;客户端提交操作指令,接收完整游戏状态与战斗事件日志,客户端不推演卡牌业务逻辑。 本架构
Intent + Sync + 独立事件流的数据流范式与之有相似之处;核心区别在于:商业产品权威源是中立官方服务器;本架构的权威源是对局内玩家主机 (P1),属于 P2P‑Host;同时本架构增加了客机完整本地业务校验能力。 -
锁步帧同步 (Lockstep):对局双方客户端均完整运行全部游戏逻辑,互相转发操作指令,依靠两端逻辑 100% 确定性一致来同步。缺点是任意两端逻辑实现微小差异,直接造成状态分裂。本架构不采用锁步,连锁效果仅在 Host 单端完成运算,规避锁步带来的逻辑一致性风险。
文档作用域与扩展说明
本文档描述的是 P2P 局域网版本:面向双人局域网环境的主机权威 P2P‑Host 实现。
-
性能边界区分部署形态
- 局域网 P2P 形态:无中心服务器参与对局逻辑,不存在服务器高并发压力;性能瓶颈来源于单局数据规模、状态包体积、WebRTC 消息传输限制。
- 当热插拔权威源,迁移至服务端权威公网形态后,才需要面对服务端 CPU 算力、对局并发量、公网高延迟等性能问题,该部分不在本文档实现范围内。
-
安全范围界定 局域网 P2P 环境中,主机客户端具备本地内存篡改能力;局域网对局设计上不接入线上排行榜、奖励系统,因此本文档不做公网级防作弊安全实现。 迁移为公网服务端权威形态时,消息协议、数据模型、uid 实体机制、who 参数模型均可复用;但必须在服务端额外叠加安全层:意图参数校验、防重放、输入防御校验。
⚠️ 注意:本架构保留的客户端本地校验仅用于提供即时 UI 交互反馈;公网服务端模式下绝不信任客户端校验结果,全部业务防御校验必须由服务端完成。
简言之:架构支持权威源热插拔切换,但公网并发、安全防御属于扩展分支需求,不属于局域网 P2P 版本的职责。
适用与不适用场景
✅ 适合场景
- 双人回合制对战游戏,存在大量连锁、响应式触发效果。
- 局域网 P2P 联机场景,优先开发效率,不想维护对局逻辑服务器。
- 项目未来有计划迁移为公网服务端权威模式,希望协议、数据模型可复用。
❌ 不适合场景
- 2人以上多人对战;本架构设计仅针对双人对局。
- 高频率实时动作类游戏(帧间隔毫秒级大量状态变更);完整 State 快照同步开销会急剧上升。
- 需要主机掉线之后对局继续运行的 P2P 环境;P2P‑Host 形态下主机为单点故障,主机断开对局直接终止。
- P2P 模式直接用于公网竞技、排行榜、奖励结算;公网竞技必须切换至服务端权威形态。
固有先天限制
即便全部工程 Bug 都被修复,基于 P2P‑Host 模型,仍然存在无法在 P2P 形态下消除的客观限制:
- 主机单点故障:Host (P1) 断开连接、关闭页面,对局直接结束,没有对局迁移、重接管主机能力。
- 主机客户端完全可信问题:局域网 Host 掌握全部游戏逻辑,本地内存可被篡改,P2P 模式无法防御主机作弊。因此 P2P 对局结果不能作为线上排名、奖励依据。
- WebRTC NAT 穿透失败:部分网络环境下无法建立直连,无法 P2P 联机,只能回落到本地单机模式。
以上限制,只有切换到服务端权威部署形态才能解决。
Intent 意图幂等性说明
本协议依赖 WebRTC 保证消息可靠有序传输。业务层面没有实现 Intent 防重复幂等 ID。
- 在理想 P2P 局域网环境,消息不会重复投递,该问题影响很小。
- 如果未来迁移到服务端权威公网版本:必须增加 intent 请求 ID,做幂等与防重放处理,避免同一个意图被多次执行。该部分属于扩展工作,不在本文档 P2P 实现范围内。
📚 附录: 通用代码模板
连接层骨架 (~270 行)
host(shortCode)- 创建房间join(shortCode)- 加入房间send(data)- 发送消息onMessage(callback)- 接收消息
同步层骨架 (~1500 行)
Sync.sendIntent(...)- 客户端发意图Sync._hostHandleIntent(data)- 主机处理意图Sync._hostBroadcastSync(events, reason)- 广播状态Sync._clientApplySync(packet)- 客机应用状态Sync._playEvent(event)- 客机播放事件
游戏规则 (业务实现, 文档不涉及)
- 具体的出牌/动作函数 (业务逻辑, 由游戏决定)
- 回合结束函数 (业务逻辑, 由游戏决定)
注: 游戏规则的具体实现由具体游戏的业务逻辑决定, 不在本文档范围内。
🎯 总结
本套 P2P 联机架构核心:
- P1 权威 + P2 验证 (简单可靠)
- State 固定分配 + who 参数 (零污染)
- Intent + Sync 双通道 (各司其职)
- 事件流 push + 状态 pull (动画流畅)
- 完整 state 广播 (实现简单)
这套架构可以直接复用到:
- 其他 TCG 卡牌游戏
- 棋类对战游戏 (象棋、围棋)
- 回合制策略游戏
- 任何"双玩家顺序操作"的游戏
核心思想:
把网络问题集中在 sync 层, 游戏逻辑层保持纯粹 (who 参数化), 避免 swap 类临时状态操作。
✍️ 作者签名
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
《灵魂契约》TCG 卡牌联机功能文档
作者:Vison
文档版本: v2.1.2-FINAL-27.270b
创建日期: 2026-08-29
"把网络问题集中在 sync 层, 让游戏逻辑保持纯粹。"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
感谢阅读! 🚀
如果你觉得这份文档有帮助, 欢迎分享给其他游戏开发者。