Compare commits
4 Commits
6ce38937eb
...
de24868e9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de24868e9b | ||
|
|
de6d88c46c | ||
|
|
55170a33d1 | ||
|
|
4ce3e62135 |
29
Plugin.cs
29
Plugin.cs
@@ -1,12 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using BepInEx;
|
||||
using BepInEx.Configuration;
|
||||
using UnityEngine;
|
||||
using BepInEx;
|
||||
using Steamworks;
|
||||
using HarmonyLib;
|
||||
using System.Data;
|
||||
using stick.plugins.kcpable.Helper;
|
||||
using stick.plugins.kcpable.Transport;
|
||||
using UnityEngine;
|
||||
|
||||
namespace stick.plugins.kcpable;
|
||||
|
||||
@@ -14,9 +10,28 @@ namespace stick.plugins.kcpable;
|
||||
[BepInProcess("StickFight.exe")]
|
||||
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()
|
||||
{
|
||||
TimeStore.UpdateTime();
|
||||
ConnectionManager.Instance.Update();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
ConnectionManager.Instance.Shutdown();
|
||||
}
|
||||
}
|
||||
237
Transport/ConnectionManager.cs
Normal file
237
Transport/ConnectionManager.cs
Normal 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 发送队列并返回 true,Patch 层应拦截原 TCP 发送。
|
||||
/// 若未就绪,返回 false,Patch 层继续走原 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);
|
||||
}
|
||||
}
|
||||
130
Transport/ConnectionState.cs
Normal file
130
Transport/ConnectionState.cs
Normal 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
281
plans/design-kcp-state.md
Normal 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` 结果稳定,无影响。 |
|
||||
87
plans/lobby-sync-hello-optimization.md
Normal file
87
plans/lobby-sync-hello-optimization.md
Normal 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 包和更准确的连接生命周期管理,收益显著。
|
||||
Reference in New Issue
Block a user