平台通知

全屏浏览 VibeHub

全屏浏览,获得更沉浸的体验。你可以随时通过菜单退出全屏。

社区技巧
开发技巧高级🎮 游戏

基于《灵魂契约》TCG 卡牌游戏联机功能的开发文档分享

> 一份关于卡牌/棋类/回合制游戏 P2P 联机架构的实现总结 > 版本: v2.1.2-FINAL-27.270b > 适用场景: 任何需要双人/多人对战的卡牌/棋类/回合制游戏

由 小呀小贤菜 分享2026年9月24日

局域网联机架构文档

《灵魂契约》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 联机功能。文档不包含任何具体游戏机制、卡牌数据、平衡数值、商业逻辑等专有内容。如需参考本文档的设计思路, 请保留对原作者的致谢。

本文档可自由用于学习和参考, 但请勿用于商业用途的转售或大规模转载。


📑 目录

  1. 架构概览
  2. 为什么选择这个架构
  3. 核心设计原则
  4. 代码组织
  5. 连接层:短码 P2P
  6. 数据模型:固定玩家视角分配
  7. 同步协议:双通道设计
  8. 事件流:实时动画同步
  9. 常见坑和解决方案
  10. 可复用的设计模式清单
  11. 本架构对比传统 P2P‑Host 的优化点

📋 版本演进记录

版本日期变更说明
v2.1.2‑FINAL‑27.270b2026‑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.jsP2P 连接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 双通道设计

通道方向触发内容时机
IntentP2 → P1玩家操作想做什么 (idx, slot, id)实时
SyncP1 → P2P1 状态变化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 - 动作 A
  • intent_action_b - 动作 B
  • intent_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常见实现:要么只传完整状态快照,动画卡顿;要么只传增量指令,状态漂移后难以修复。事件经常缓冲等到回合结束批量发送,动画延迟严重。
  • 本架构优化:
    1. Intent 通道:客户端发送操作意图,轻量实时;
    2. Sync 通道:主机广播完整序列化状态做兜底;
    3. 战斗事件独立 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 在卡牌游戏里高频遇到的状态污染、规则不一致、动画丢失、对象引用失效等工程问题。

与商业产品架构对比

  1. 商业 TCG(服务端权威):所有卡牌连锁、触发逻辑全部在官方中央服务器运算;客户端提交操作指令,接收完整游戏状态与战斗事件日志,客户端不推演卡牌业务逻辑。 本架构Intent + Sync + 独立事件流的数据流范式与之有相似之处;核心区别在于:商业产品权威源是中立官方服务器;本架构的权威源是对局内玩家主机 (P1),属于 P2P‑Host;同时本架构增加了客机完整本地业务校验能力。

  2. 锁步帧同步 (Lockstep):对局双方客户端均完整运行全部游戏逻辑,互相转发操作指令,依靠两端逻辑 100% 确定性一致来同步。缺点是任意两端逻辑实现微小差异,直接造成状态分裂。本架构不采用锁步,连锁效果仅在 Host 单端完成运算,规避锁步带来的逻辑一致性风险。

文档作用域与扩展说明

本文档描述的是 P2P 局域网版本:面向双人局域网环境的主机权威 P2P‑Host 实现。

  1. 性能边界区分部署形态

    • 局域网 P2P 形态:无中心服务器参与对局逻辑,不存在服务器高并发压力;性能瓶颈来源于单局数据规模、状态包体积、WebRTC 消息传输限制。
    • 当热插拔权威源,迁移至服务端权威公网形态后,才需要面对服务端 CPU 算力、对局并发量、公网高延迟等性能问题,该部分不在本文档实现范围内。
  2. 安全范围界定 局域网 P2P 环境中,主机客户端具备本地内存篡改能力;局域网对局设计上不接入线上排行榜、奖励系统,因此本文档不做公网级防作弊安全实现。 迁移为公网服务端权威形态时,消息协议、数据模型、uid 实体机制、who 参数模型均可复用;但必须在服务端额外叠加安全层:意图参数校验、防重放、输入防御校验。

    ⚠️ 注意:本架构保留的客户端本地校验仅用于提供即时 UI 交互反馈;公网服务端模式下绝不信任客户端校验结果,全部业务防御校验必须由服务端完成。

    简言之:架构支持权威源热插拔切换,但公网并发、安全防御属于扩展分支需求,不属于局域网 P2P 版本的职责。

适用与不适用场景

✅ 适合场景

  1. 双人回合制对战游戏,存在大量连锁、响应式触发效果。
  2. 局域网 P2P 联机场景,优先开发效率,不想维护对局逻辑服务器。
  3. 项目未来有计划迁移为公网服务端权威模式,希望协议、数据模型可复用。

❌ 不适合场景

  1. 2人以上多人对战;本架构设计仅针对双人对局。
  2. 高频率实时动作类游戏(帧间隔毫秒级大量状态变更);完整 State 快照同步开销会急剧上升。
  3. 需要主机掉线之后对局继续运行的 P2P 环境;P2P‑Host 形态下主机为单点故障,主机断开对局直接终止。
  4. P2P 模式直接用于公网竞技、排行榜、奖励结算;公网竞技必须切换至服务端权威形态。

固有先天限制

即便全部工程 Bug 都被修复,基于 P2P‑Host 模型,仍然存在无法在 P2P 形态下消除的客观限制:

  1. 主机单点故障:Host (P1) 断开连接、关闭页面,对局直接结束,没有对局迁移、重接管主机能力。
  2. 主机客户端完全可信问题:局域网 Host 掌握全部游戏逻辑,本地内存可被篡改,P2P 模式无法防御主机作弊。因此 P2P 对局结果不能作为线上排名、奖励依据。
  3. 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 联机架构核心:

  1. P1 权威 + P2 验证 (简单可靠)
  2. State 固定分配 + who 参数 (零污染)
  3. Intent + Sync 双通道 (各司其职)
  4. 事件流 push + 状态 pull (动画流畅)
  5. 完整 state 广播 (实现简单)

这套架构可以直接复用到:

  • 其他 TCG 卡牌游戏
  • 棋类对战游戏 (象棋、围棋)
  • 回合制策略游戏
  • 任何"双玩家顺序操作"的游戏

核心思想:

把网络问题集中在 sync 层, 游戏逻辑层保持纯粹 (who 参数化), 避免 swap 类临时状态操作。


✍️ 作者签名

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                                                     
   《灵魂契约》TCG 卡牌联机功能文档                 
                                                     
   作者:Vison                                       
                                                     
   文档版本: v2.1.2-FINAL-27.270b                    
   创建日期: 2026-08-29                              
                                                     
   "把网络问题集中在 sync 层, 让游戏逻辑保持纯粹。"   
                                                     
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

感谢阅读! 🚀

如果你觉得这份文档有帮助, 欢迎分享给其他游戏开发者。