Compare commits

..

4 Commits

Author SHA1 Message Date
Yue-bin
de24868e9b feat(transport): 添加大厅成员同步功能
添加了_currentLobbyId字段用于跟踪当前大厅ID,实现了基于SteamMatchmaking
API的大厅成员列表同步机制。新增SetCurrentLobby、ClearLobby和
RefreshLobbyMembers方法,支持自动检测大厅成员变化并同步连接状态。

BREAKING CHANGE: 重构了OnPeerJoined/OnPeerLeft为SetCurrentLobby/ClearLobby,
改变了外部调用接口。
2026-04-23 01:17:04 +08:00
Yue-bin
de6d88c46c docs(ConnectionManager): 实现大厅同步机制替代房间事件监听
- 引入 SteamMatchmaking API 通过 SetCurrentLobby 和
  RefreshLobbyMembers 接口进行大厅成员同步
- 采用集合差分算法精确识别新增/离开的对端玩家,
  避免全房间广播 Hello 包
- 重构 PeerConnection 握手重试机制,改用时间驱动
  的 HelloSchedule 替代计数驱动
- 更新 ConnectionManager API,移除 OnPeerJoined/
  OnPeerLeft 方法,新增大厅管理相关接口
- 完善 KCP 连接生命周期管理,确保离开玩家的
  连接能够及时清理
- 添加详细的状态机图表和时序说明文档
2026-04-23 01:16:09 +08:00
Yue-bin
55170a33d1 feat(kcpable): 添加KCP连接管理器实现可靠UDP传输
- 实现ConnectionManager作为全局KCP连接管理器,负责管理所有对端PeerConnection
- 添加PeerConnection类处理握手状态管理和KCP实例生命周期
- 支持Handshaking、Idle、Connected三种连接状态
- 实现Hello握手包冗余广播机制确保连接建立
- 提供TrySend方法供游戏逻辑发送数据到KCP通道
- 在Plugin中集成ConnectionManager的初始化和更新循环
- 添加Steam初始化检查,未初始化时禁用KCP功能
- 实现每帧Update驱动KCP状态更新和握手广播调度
2026-04-23 00:21:51 +08:00
Yue-bin
4ce3e62135 docs(ConnectionManager): 添加KCP切换状态管理设计文档
- 设计了透明握手机制,利用原游戏TCP栈发送Hello握手包,
  对不支持KCP的对端无影响
- 定义了基于PeerConnection粒度的状态机,管理从TCP到KCP的切换生命周期
- 实现频道隔离,高频同步(Channel 0)和事件下发(Channel 1)
  各自拥有独立的KCP实例
- 提供向下兼容方案,握手失败或超时时保持在原TCP栈(Idle状态)
- 包含完整的状态机设计、核心类结构和关键交互时序说明
2026-04-23 00:21:32 +08:00
5 changed files with 757 additions and 7 deletions

View File

@@ -1,12 +1,8 @@
using System.Collections.Generic; using BepInEx;
using System.IO;
using BepInEx;
using BepInEx.Configuration;
using UnityEngine;
using Steamworks; using Steamworks;
using HarmonyLib;
using System.Data;
using stick.plugins.kcpable.Helper; using stick.plugins.kcpable.Helper;
using stick.plugins.kcpable.Transport;
using UnityEngine;
namespace stick.plugins.kcpable; namespace stick.plugins.kcpable;
@@ -14,9 +10,28 @@ namespace stick.plugins.kcpable;
[BepInProcess("StickFight.exe")] [BepInProcess("StickFight.exe")]
public class Plugin : BaseUnityPlugin public class Plugin : BaseUnityPlugin
{ {
void Awake()
{
if (!SteamManager.Initialized)
{
Logger.LogWarning("Steam not initialized, Kcpable will not be active.");
return;
}
ConnectionManager.Instance.Initialize(SteamUser.GetSteamID());
// Patch 层应在后续注册 SendHello / OnKcpOutput
Logger.LogInfo("Kcpable initialized.");
}
void Update() void Update()
{ {
TimeStore.UpdateTime(); TimeStore.UpdateTime();
ConnectionManager.Instance.Update();
} }
void OnDestroy()
{
ConnectionManager.Instance.Shutdown();
}
} }

View File

@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using Steamworks;
using stick.plugins.kcpable.Helper;
using UnityEngine;
namespace stick.plugins.kcpable.Transport;
/// <summary>
/// 全局 KCP 连接管理器:管理所有对端 PeerConnection驱动握手与 KCP 状态更新。
/// Patch 层通过注册 <see cref="OnKcpOutput"/> 和 <see cref="SendHello"/> 完成实际收发。
/// </summary>
public class ConnectionManager
{
public static ConnectionManager Instance { get; private set; } = new();
private CSteamID _localSteamId;
private CSteamID? _currentLobbyId;
private readonly Dictionary<CSteamID, PeerConnection> _peers = new();
/// <summary>
/// Patch 层注册:当 KCP 需要发送底层 UDP 数据时触发。
/// 参数peerSteamId, channel, buffer, size
/// </summary>
public Action<CSteamID, int, byte[], int> OnKcpOutput { get; set; }
/// <summary>
/// Patch 层注册:当需要发送 Hello 握手包时触发。
/// Hello 内容固定version 字节Patch 层只需通过原 TCP 栈发给指定对端。
/// </summary>
public Action<CSteamID> SendHello { get; set; }
public void Initialize(CSteamID localSteamId)
{
_localSteamId = localSteamId;
_peers.Clear();
_currentLobbyId = null;
}
public void Shutdown()
{
ClearLobby();
}
/// <summary>
/// Patch 层在进入大厅后调用,设置当前大厅 ID 并立即开始轮询成员列表。
/// </summary>
public void SetCurrentLobby(CSteamID lobbyId)
{
if (_currentLobbyId.HasValue && _currentLobbyId.Value == lobbyId)
return;
ClearLobby();
_currentLobbyId = lobbyId;
SyncLobbyMembers();
}
/// <summary>
/// Patch 层在离开大厅或退出到主菜单时调用,清理所有连接状态。
/// </summary>
public void ClearLobby()
{
foreach (var peer in _peers.Values)
{
peer.MarkIdle();
}
_peers.Clear();
_currentLobbyId = null;
}
/// <summary>
/// Patch 层可选调用:在收到 lobby 数据更新事件时立即刷新一次成员列表。
/// 核心逻辑不依赖此调用,因为 Update 中已经每帧轮询。
/// </summary>
public void RefreshLobbyMembers()
{
if (_currentLobbyId.HasValue)
SyncLobbyMembers();
}
/// <summary>
/// Patch 层收到 Hello 包时调用。
/// 无论当前状态是 Handshaking 还是 Idle一律升级为 Connected。
/// </summary>
public void OnHelloReceived(CSteamID fromPeer)
{
if (fromPeer == _localSteamId)
return;
if (!_peers.TryGetValue(fromPeer, out var peer))
{
// 对端先发了 Hello但本地尚未通过大厅同步发现该玩家
// 直接创建并标记为 Connected
peer = new PeerConnection(fromPeer, _localSteamId, OnPeerKcpOutput);
_peers[fromPeer] = peer;
}
peer.MarkConnected();
}
/// <summary>
/// Patch 层收到 KCP 底层 UDP 数据时调用。
/// </summary>
public void OnKcpDataReceived(CSteamID peerId, int channel, byte[] data, int offset, int length)
{
if (!_peers.TryGetValue(peerId, out var peer))
return;
if (peer.State != PeerState.Connected)
return;
if (!peer.KcpChannels.TryGetValue(channel, out var kcp))
return;
kcp.Input(data, offset, length);
}
/// <summary>
/// 游戏逻辑尝试发送数据时调用。
/// 若对端已 Connected数据进入 KCP 发送队列并返回 truePatch 层应拦截原 TCP 发送。
/// 若未就绪,返回 falsePatch 层继续走原 TCP 栈。
/// </summary>
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length)
{
if (!_peers.TryGetValue(peerId, out var peer))
return false;
if (peer.State != PeerState.Connected)
return false;
if (!peer.KcpChannels.TryGetValue(channel, out var kcp))
return false;
kcp.Send(data, 0, length);
return true;
}
/// <summary>
/// 每帧调用:驱动 Hello 冗余广播调度 + KCP Update。
/// 大厅成员同步由 Patch 层通过 <see cref="RefreshLobbyMembers"/> 主动触发。
/// </summary>
public void Update()
{
float currentTime = Time.time;
uint currentMs = (uint)TimeStore.GetTimeMs();
foreach (var peer in _peers.Values)
{
// 驱动 Hello 冗余广播
if (peer.State == PeerState.Handshaking)
{
if (peer.TryTakeHelloSend(currentTime, out _))
{
SendHello?.Invoke(peer.PeerSteamId);
}
}
// 驱动 KCP 状态机
peer.UpdateKcp(currentMs);
}
}
/// <summary>
/// 通过 SteamMatchmaking API 拉取当前大厅全量成员列表,与内部 _peers 做集合差分。
/// 新增成员创建 Handshaking 连接,离开成员清理并移除。
/// </summary>
private void SyncLobbyMembers()
{
if (!_currentLobbyId.HasValue)
return;
var lobbyId = _currentLobbyId.Value;
int memberCount = SteamMatchmaking.GetNumLobbyMembers(lobbyId);
var currentMembers = new HashSet<CSteamID>();
for (int i = 0; i < memberCount; i++)
{
CSteamID memberId = SteamMatchmaking.GetLobbyMemberByIndex(lobbyId, i);
if (memberId != _localSteamId)
currentMembers.Add(memberId);
}
// 新增 peer大厅有但 _peers 没有
foreach (var peerId in currentMembers)
{
if (!_peers.ContainsKey(peerId))
{
AddPeer(peerId);
}
}
// 离开 peer_peers 有,但大厅没有
var toRemove = new List<CSteamID>();
foreach (var peerId in _peers.Keys)
{
if (!currentMembers.Contains(peerId))
toRemove.Add(peerId);
}
foreach (var peerId in toRemove)
{
RemovePeer(peerId);
}
}
/// <summary>
/// 内部方法:创建 Handshaking 状态的 PeerConnection。
/// </summary>
private void AddPeer(CSteamID peerId)
{
if (peerId == _localSteamId)
return;
if (_peers.ContainsKey(peerId))
return;
var peer = new PeerConnection(peerId, _localSteamId, OnPeerKcpOutput);
_peers[peerId] = peer;
}
/// <summary>
/// 内部方法:清理对应 KCP 实例并移除连接。
/// </summary>
private void RemovePeer(CSteamID peerId)
{
if (_peers.TryGetValue(peerId, out var peer))
{
peer.MarkIdle();
_peers.Remove(peerId);
}
}
private void OnPeerKcpOutput(CSteamID peerId, int channel, byte[] data, int size)
{
OnKcpOutput?.Invoke(peerId, channel, data, size);
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using Steamworks;
using stick.plugins.kcpable.Protocol;
using stick.plugins.kcpable.Transport.kcp;
using UnityEngine;
namespace stick.plugins.kcpable.Transport;
public enum PeerState
{
Handshaking, // 握手中,此为初始状态,也就是应该立刻开始握手,这之前的状态是不相干的
Idle, // 保持在原本的tcp连接状态
Connected, // 使用kcp连接
}
public class PeerConnection
{
// 固定分时冗余广播时间点(秒,相对 Handshaking 开始时刻)
private static readonly float[] HelloSchedule = { 0f, 0.5f, 1.0f };
public CSteamID PeerSteamId { get; }
public PeerState State { get; private set; } = PeerState.Handshaking;
// 握手冗余广播
public float HelloPhaseStartTime { get; private set; }
private int _helloSentIndex = -1; // -1=未开始, 0/1/2=已发到第几个
// KCP 实例延迟初始化key: channel
public Dictionary<int, Kcp> KcpChannels { get; } = new();
// 按 channel 的 conv 生成器
private readonly Dictionary<int, ConvGenerator> _convGens = new();
// KCP 数据输出回调peerId, channel, buffer, size
private readonly Action<CSteamID, int, byte[], int> _kcpOutput;
public PeerConnection(CSteamID peerSteamId, CSteamID localSteamId, Action<CSteamID, int, byte[], int> kcpOutput)
{
PeerSteamId = peerSteamId;
_kcpOutput = kcpOutput;
_convGens[0] = new ConvGenerator(localSteamId, peerSteamId, 0);
_convGens[1] = new ConvGenerator(localSteamId, peerSteamId, 1);
HelloPhaseStartTime = Time.time;
}
/// <summary>
/// 检查当前是否需要发送 Hello。若 3 次全部发完仍未升级,则降级为 Idle。
/// </summary>
public bool TryTakeHelloSend(float currentTime, out float? nextSendTime)
{
nextSendTime = null;
if (State != PeerState.Handshaking)
return false;
int nextIndex = _helloSentIndex + 1;
if (nextIndex >= HelloSchedule.Length)
{
// 冗余广播耗尽,降级为 Idle
MarkIdle();
return false;
}
float targetTime = HelloPhaseStartTime + HelloSchedule[nextIndex];
if (currentTime >= targetTime)
{
_helloSentIndex = nextIndex;
if (nextIndex + 1 < HelloSchedule.Length)
nextSendTime = HelloPhaseStartTime + HelloSchedule[nextIndex + 1];
return true;
}
nextSendTime = targetTime;
return false;
}
public void MarkConnected()
{
if (State == PeerState.Connected)
return;
State = PeerState.Connected;
EnsureKcpCreated();
}
public void MarkIdle()
{
if (State == PeerState.Idle)
return;
State = PeerState.Idle;
DisposeKcpChannels();
}
private void EnsureKcpCreated()
{
if (KcpChannels.Count > 0)
return;
foreach (int channel in new[] { 0, 1 })
{
uint conv = _convGens[channel].Generate();
int capturedChannel = channel;
var kcp = new Kcp(conv, (data, size) => _kcpOutput(PeerSteamId, capturedChannel, data, size));
// Turbo 模式:低延迟、快速重传、关闭拥塞窗口限制
kcp.SetNoDelay(1, 10, 2, true);
KcpChannels[channel] = kcp;
}
}
private void DisposeKcpChannels()
{
// Kcp 本身无显式 Dispose但释放字典引用即可让 GC 回收
KcpChannels.Clear();
}
public void UpdateKcp(uint currentTimeMs)
{
if (State != PeerState.Connected)
return;
foreach (var kvp in KcpChannels)
{
kvp.Value.Update(currentTimeMs);
}
}
}

281
plans/design-kcp-state.md Normal file
View File

@@ -0,0 +1,281 @@
# KCP 切换状态管理设计文档
## 1. 设计目标
- **透明握手**利用原游戏TCP栈发送Hello握手包对不支持KCP的对端无影响。
- **状态驱动**按PeerConnection粒度管理从TCP到KCP的切换生命周期。
- **频道隔离**高频同步Channel 0和事件下发Channel 1各自拥有独立的KCP实例。
- **向下兼容**握手失败或超时后保持在原TCP栈Idle状态不影响游戏。
- **大厅同步**:通过 SteamMatchmaking API 拉取大厅成员列表,与内部 _peers 做集合差分,精确触发 Hello 和连接清理,避免向全房间广播。
## 2. 状态机
```mermaid
stateDiagram-v2
[*] --> Handshaking : 大厅同步发现新对端 / 收到对端Hello
Handshaking --> Idle : 握手超时MaxRetry次
Handshaking --> Connected : 收到对端Hello双向确认
Idle --> Handshaking : 大厅同步发现新对端
Connected --> [*] : 大厅同步发现对端离开 / 连接断开
```
### 状态说明
| 状态 | 含义 | 发送行为 | 接收行为 |
| --------------- | --------------- | ----------------- | -------------------------- |
| **Handshaking** | 正在尝试协商KCP | 周期性发送Hello包 | 收到Hello后升级到Connected |
| **Idle** | 保持原TCP连接 | 不发Hello | 走游戏默认TCP栈 |
| **Connected** | 使用KCP连接 | 走KCP发送 | KCP Input处理数据 |
> 注意:`Handshaking`是本地视角的"我已发送Hello"。只要收到对端的Hello包无论之前是否发过就视为对端支持KCP立即升级到`Connected`。这简化了握手逻辑——不需要显式Ack因为收到合法Hello本身就说明对端已确认。
## 3. 核心类设计
### 3.1 PeerConnection单对端连接
```csharp
public class PeerConnection
{
// 身份标识
public CSteamID PeerSteamId { get; }
// 状态机
public PeerState State { get; private set; } = PeerState.Handshaking;
// 握手重试(时间驱动,非计数驱动)
public float HelloPhaseStartTime { get; private set; }
private int _helloSentIndex = -1;
private static readonly float[] HelloSchedule = { 0f, 0.5f, 1.0f };
// KCP实例按channel隔离
// channel 0: 高频位置同步
// channel 1: 事件下发
public Dictionary<int, Kcp> KcpChannels { get; }
// 按 channel 的 conv 生成器(预协商,无需网络交换)
private readonly Dictionary<int, ConvGenerator> _convGens;
// 状态转换方法
public void MarkConnected();
public void MarkIdle();
public bool TryTakeHelloSend(float currentTime, out float? nextSendTime);
}
```
### 3.2 ConnectionManager全局管理
```csharp
public class ConnectionManager
{
// 单例
public static ConnectionManager Instance { get; }
// 所有对端连接
private readonly Dictionary<CSteamID, PeerConnection> _peers = new();
// 本地SteamID缓存避免重复Get
private CSteamID _localSteamId;
// 当前大厅ID为null时表示未进入大厅
private CSteamID? _currentLobbyId;
// === 生命周期 ===
public void Initialize(CSteamID localSteamId);
public void Shutdown(); // 清理所有KCP实例
// === 大厅同步 ===
public void SetCurrentLobby(CSteamID lobbyId); // 进入大厅时调用
public void ClearLobby(); // 离开大厅时调用
public void RefreshLobbyMembers(); // 大厅数据更新时调用
// === 握手处理 ===
public void OnHelloReceived(CSteamID fromPeer); // Patch层收到Hello时调用
// === 数据收发Patch层调用===
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length);
public void OnKcpDataReceived(CSteamID peerId, int channel, byte[] data, int offset, int length);
// === Tick驱动 ===
public void Update(); // 每帧调用驱动KCP Update + 握手重试。大厅同步由Patch层事件触发。
// === 内部方法 ===
private void SyncLobbyMembers(); // 通过 Steam API 拉取并做集合差分
private void AddPeer(CSteamID peerId);
private void RemovePeer(CSteamID peerId);
}
```
### 3.3 Patch层接口由你实现
Patch层只需要调用以下接口无需关心内部状态
```csharp
// 初始化Plugin.Awake
ConnectionManager.Instance.Initialize(SteamUser.GetSteamID());
// 进入大厅Patch OnLobbyEnter
ConnectionManager.Instance.SetCurrentLobby(lobbyId);
// 离开大厅或退出到主菜单Patch OnLobbyLeave 或相应逻辑)
ConnectionManager.Instance.ClearLobby();
// 大厅数据更新Patch OnLobbyDataUpdate / OnLobbyChatUpdate
ConnectionManager.Instance.RefreshLobbyMembers();
// 收到Hello包Patch消息分发
ConnectionManager.Instance.OnHelloReceived(fromPeer);
// 收到KCP原始UDP数据Patch P2P接收回调
ConnectionManager.Instance.OnKcpDataReceived(fromPeer, channel, data, offset, length);
// 发送游戏数据Patch发送前判断是否走KCP
// 若返回true表示已由KCP接管Patch应拦截原TCP发送
bool sent = ConnectionManager.Instance.TrySend(peerId, channel, data, length);
// 每帧更新Plugin.Update
ConnectionManager.Instance.Update();
```
## 4. 关键交互时序
### 4.1 正常握手双方都支持KCP
```
Local Peer Remote Peer
| |
|--- Hello (via TCP) ---------------->|
|<-- Hello (via TCP) -----------------|
| |
|[State: Handshaking -> Connected] |[State: Handshaking -> Connected]
| |
|--- KCP Data (via UDP) ------------->|
|<-- KCP Data (via UDP) --------------|
```
### 4.2 单方支持KCP另一方丢弃Hello
```
Local Peer Remote Peer (原版)
| |
|--- Hello (via TCP) ---------------->|
| [Drop: unknown MsgType]
| |
|[Retry 1] |
|--- Hello (via TCP) ---------------->|
| [Drop]
|[Retry 2] |
|--- Hello (via TCP) ---------------->|
| [Drop]
|[Retry 3] |
|--- Hello (via TCP) ---------------->|
| [Drop]
|[State: Handshaking -> Idle] |
|[后续数据继续走TCP] |
```
### 4.3 大厅同步触发 Hello新成员加入
```
Patch 层收到大厅事件OnLobbyEnter / OnLobbyDataUpdate / OnLobbyChatUpdate
|
v
调用 SetCurrentLobby() 或 RefreshLobbyMembers()
|
v
SteamMatchmaking.GetNumLobbyMembers -> GetLobbyMemberByIndex
|
v
与 _peers.Keys 做集合差分
|
+-- 新增 peerId --> AddPeer() --> Handshaking --> 按 HelloSchedule 发 Hello
|
+-- 离开 peerId --> RemovePeer() --> MarkIdle() --> 清理 KCP
|
+-- 已有 peerId --> 无操作
```
## 5. 数据流图
```mermaid
graph TD
A[游戏逻辑] -->|发送数据| B[Patch层]
B --> C{ConnectionManager}
C -->|State=Connected| D[Kcp.Send]
C -->|State!=Connected| E[原TCP发送]
D --> F[Steam P2P UDP]
E --> G[原游戏TCP栈]
H[Steam P2P UDP接收] --> I[Patch层]
I --> J{MsgType}
J -->|Hello| C
J -->|KCP Raw| K[Kcp.Input]
K --> L[游戏逻辑接收]
M[SteamMatchmaking API] -->|大厅成员列表| C
```
## 6. 实现要点
### 6.1 Hello发送策略
- Patch 层通过 `SetCurrentLobby()``RefreshLobbyMembers()` 触发 `SyncLobbyMembers()`
- `SyncLobbyMembers()` 通过 Steam API 拉取全量成员列表,与 `_peers.Keys` 做集合差分。
- 对于**新 diff 出来的成员**,创建 `PeerConnection` 并置为 `Handshaking`,按固定时间表发送 Hello默认 `0s, 0.5s, 1.0s`)。
- Hello 冗余次数后续会做成配置项,当前固定为 3 次。
- 对于**已握手的 peer**,不再重复发送 Hello。
- 对于**已离开的 peer**,立即清理连接。
### 6.2 KCP实例初始化时机
- **延迟初始化**:不要在创建 `PeerConnection` 时就创建KCP实例而是等到状态变为 `Connected` 时才创建。
- **原因**避免与不支持KCP的对端浪费资源同时确保conv生成器的参数已就绪。
```csharp
private void EnsureKcpCreated()
{
if (KcpChannels.Count > 0) return;
foreach (int channel in new[] { 0, 1 })
{
uint conv = _convGens[channel].Generate();
int capturedChannel = channel;
var kcp = new Kcp(conv, (data, size) => _kcpOutput(PeerSteamId, capturedChannel, data, size));
kcp.SetNoDelay(1, 10, 2, true); // Turbo模式适合游戏
KcpChannels[channel] = kcp;
}
}
```
### 6.3 KCP Update调度
-`ConnectionManager.Update()` 中,对所有 `Connected` 状态的连接遍历其 `KcpChannels`
- 大厅同步由 Patch 层事件触发,不在 `Update()` 中执行。
- 每个KCP实例调用 `Update(currentTimeMs)`
- 使用 `TimeStore.GetTimeMs()` 作为统一时间源。
### 6.4 发送接管逻辑
```csharp
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length)
{
if (!_peers.TryGetValue(peerId, out var peer)) return false;
if (peer.State != PeerState.Connected) return false;
if (!peer.KcpChannels.TryGetValue(channel, out var kcp)) return false;
kcp.Send(data, 0, length);
return true;
}
```
Patch层在拦截到游戏发送逻辑时先调用 `TrySend`
- 返回 `true`已由KCP接管不再走原TCP。
- 返回 `false`KCP未就绪继续走原TCP。
### 6.5 大厅同步边界情况
| 场景 | 处理 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 玩家闪退Steam API 短暂仍显示该玩家 | 下次 `RefreshLobbyMembers` 调用会自然 diff 出来Hello 发送窗口已过的新 peer 不会重发;离开的 peer 连接会被清理。 |
| 对端先发了 Hello但本地 Steam API 还没同步到 | `OnHelloReceived` 会兜底创建 `PeerConnection` 并直接 `Connected`。后续 `SyncLobbyMembers` 发现大厅列表里有此人,不会做重复添加。 |
| 本地尚未进入大厅(`_currentLobbyId` 为 null | `SyncLobbyMembers` 不执行,`_peers` 保持为空。 |
| Host 拒绝新人加入 | 大厅人数固定后,`SyncLobbyMembers` 结果稳定,无影响。 |

View File

@@ -0,0 +1,87 @@
# Lobby 同步与 Hello 优化设计
## 背景与问题
当前 `ConnectionManager` 依赖 Patch 层通过 `OnPeerJoined`/`OnPeerLeft` 提供精确的成员变动事件,但游戏 hook 只能告知"成员有变化",无法给出 delta谁进了/谁出了)。这导致:
1. 不得不对全房间所有人发 Hello对已握手的 peer 重复发送显得粗鲁。
2. 无法准确清理已离开玩家的 KCP 连接。
3. 另一个插件已经在做大厅成员列表维护,不想重复也不想产生依赖。
## 方案概述
**采用方案一**`ConnectionManager` 内部通过 `SteamMatchmaking` API 拉取当前大厅成员列表,并与内部 `_peers` 字典做集合差分。Patch 层在进入大厅时调用 `SetCurrentLobby(lobbyId)`,在 lobby 数据更新事件时调用 `RefreshLobbyMembers()` 触发同步。
## 核心设计
### 1. ConnectionManager 新增大厅同步
- 增加字段:`private CSteamID? _currentLobbyId`
- 增加方法:`public void SetCurrentLobby(CSteamID lobbyId)``public void RefreshLobbyMembers()`
- `SetCurrentLobby``RefreshLobbyMembers` 内部调用 `SyncLobbyMembers()` 逻辑:
1. 调用 `SteamMatchmaking.GetNumLobbyMembers(_currentLobbyId.Value)` 获取人数(上限 4含自己
2. 遍历索引,用 `GetLobbyMemberByIndex` 收集所有成员 `CSteamID`
3. 排除自己(`_localSteamId`)。
4.`_peers.Keys` 做集合差分:
- **大厅有,`_peers` 无** → 内部调用 `AddPeer(peerId)`(创建 `PeerConnection`,状态 `Handshaking`)。
- **大厅无,`_peers` 有** → 内部调用 `RemovePeer(peerId)`(清理 KCP移除字典
- **两边都有** → 无操作。
### 2. Hello 调度
Hello 冗余策略保持现状,**不做修改**。后续用户会将其做成配置项。当前只需确保 Hello 只发给真正通过 diff 新增出来的 peer 即可。
### 3. 对端先发 Hello 的场景
`OnHelloReceived` 保持现有逻辑不变:
- 如果 `_peers` 中已有该 peer升级到 `Connected`
- 如果 `_peers` 中没有(对端先发了 Hello但本地 Steam API 尚未同步到该成员),则直接创建并标记为 `Connected`
### 4. Patch 层职责简化
Patch 层不再需要 hook 任何与"具体哪位玩家加入/离开"相关的方法。
需要保留的 hook均为 lobby 级事件):
- `OnLobbyEnter``ConnectionManager.Instance.SetCurrentLobby(lobbyId)`(内部自动执行一次同步)
- `OnLobbyDataUpdate` / `OnLobbyChatUpdate` → 调用 `ConnectionManager.Instance.RefreshLobbyMembers()` 触发同步,不传递成员信息。
离开大厅时(如 `OnLobbyLeave` 或游戏退出到主菜单):
- 调用 `ConnectionManager.Instance.ClearLobby()`,内部清空 `_currentLobbyId` 并清理所有 `_peers`
### 5. 原有公开 API 调整
- `public void OnPeerJoined(CSteamID peerId)``public void OnPeerLeft(CSteamID peerId)` 改为 `private`
- 新增 `public void SetCurrentLobby(CSteamID lobbyId)`
- 新增 `public void ClearLobby()`
- 新增 `public void RefreshLobbyMembers()`(供 lobby 更新事件调用,立即执行一次同步)。
## 边界情况
| 场景 | 处理 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 玩家闪退Steam API 短暂仍显示该玩家 | 下次 `RefreshLobbyMembers` 调用会自然 diff 出来Hello 发送窗口已过的新 peer 不会重发;离开的 peer 连接会被清理。 |
| 对端先发了 Hello但本地 Steam API 还没同步到 | `OnHelloReceived` 会兜底创建 `PeerConnection` 并直接 `Connected`。后续 `SyncLobbyMembers` 发现大厅列表里有此人,不会做重复添加。 |
| 本地尚未进入大厅(`_currentLobbyId` 为 null | `SyncLobbyMembers` 不执行,`_peers` 保持为空。 |
| Host 拒绝新人加入 | 大厅人数固定后,`SyncLobbyMembers` 结果稳定,无影响。 |
## 实现步骤
1. **修改 `Transport/ConnectionManager.cs`**
- 增加 `_currentLobbyId``AddPeer``RemovePeer``SyncLobbyMembers`
- 移除 `Update()` 中的同步调用,大厅同步完全由 Patch 层触发。
- 调整 `OnPeerJoined`/`OnPeerLeft` 可见性(改为 private 或内部调用)。
- 新增 `SetCurrentLobby` / `ClearLobby` / `RefreshLobbyMembers` 公开 API。
2. **更新 Patch 层代码**(在另一个项目中或后续实现)
- `OnLobbyEnter` hook → `SetCurrentLobby`
- `OnLobbyDataUpdate` / `OnLobbyChatUpdate` hook → `RefreshLobbyMembers`
- 移除所有需要知道具体谁加入/谁离开的 hook。
3. **测试验证**
- 进入 2/3/4 人房,确认 Hello 只发给新成员。
- 成员离开后确认 KCP 连接被清理。
- 对端先 Hello 的场景确认兜底逻辑正常。
## 性能评估
大厅人数上限为 4`RefreshLobbyMembers` 仅在 lobby 事件触发时执行一次集合差分(最多 3 个 peer开销几乎为零。相比减少的无效 Hello 包和更准确的连接生命周期管理,收益显著。