5 Commits

Author SHA1 Message Date
Yue-bin
c6574c53c8 chore(playermanager): 将版本更新至 3.1.0
本次提交将 PlayerManager 插件的版本号从 3.0.4 升级至 3.1.0,以反映最近的功能更新和代码重构。主要变更包括房间公开性切换功能的添加以及玩家管理器代码结构的重构优化。
2026-02-01 20:45:26 +08:00
Yue-bin
9a0b40d3ee feat(gui): 添加房间公开性切换功能
在玩家管理界面中新增房间公开性显示和切换功能。
服务器管理员可以通过界面按钮切换房间的公开状态,
支持在"公开"和"好友可见"两种模式之间切换。
2026-02-01 20:44:32 +08:00
Yue-bin
530036cc30 refactor(gui): 重构玩家管理GUI按钮点击处理逻辑
将踢人、曾用名切换、大厅距离过滤和刷新列表等按钮的点击处理逻辑提取为独立的私有方法。提高代码可读性和可维护性,使GUI绘制方法更加简洁。
2026-02-01 19:18:14 +08:00
Yue-bin
3a2062d808 refactor(player-manager): 重构玩家管理器代码结构
为所有类和方法添加 XML 文档注释,完善代码组织结构,修复 RecoderBase/Recoder 类名拼写错误,统一使用 PascalCase 命名规范,重构 PlayerManagerGUI 和 Plugin 类以提升可读性和可维护性。
2026-02-01 19:11:25 +08:00
Yue-bin
45884570e6 refactor(player-manager): optimize performance with cached styles and O(1) lookups
- Cache GUIStyle objects instead of creating per-frame to reduce GC pressure
- Use HashSet for O(1) player lookup instead of O(n) list iteration
- Add null checks and improve error handling
2026-02-01 18:01:55 +08:00
7 changed files with 973 additions and 367 deletions

View File

@@ -1,7 +1,10 @@
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
public class BlackListRecorder : RecoderBase /// <summary>
/// 黑名单记录器,用于记录需要屏蔽的玩家
/// </summary>
public class BlackListRecorder : RecorderBase
{ {
protected override string FileName => "BlackList.txt"; protected override string FileName => "BlackList.txt";
protected override PlayerRecordStatus RecordStatus => PlayerRecordStatus.Blacklisted; protected override PlayerRecordStatus RecordStatus => PlayerRecordStatus.Blacklisted;
} }

View File

@@ -5,84 +5,134 @@ using UnityEngine;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
/// <summary>
/// 玩家信息类
/// 封装玩家的Steam ID、名称和记录状态
/// </summary>
public class PlayerInfo : IEquatable<PlayerInfo> public class PlayerInfo : IEquatable<PlayerInfo>
{ {
// 即时获取的玩家名字,只读 #region
public string PlayerName
/// <summary>
/// 玩家的Steam ID
/// </summary>
public CSteamID SteamId { get; set; }
/// <summary>
/// 记录的玩家名字(通常是曾用名)
/// </summary>
public string RecordedName { get; set; }
/// <summary>
/// 是否显示记录的名称
/// </summary>
public bool DisplayNameRecorded { get; set; }
/// <summary>
/// 是否有记录的名称(与当前名称不同)
/// </summary>
public bool HasRecordedName => !string.IsNullOrEmpty(RecordedName) && RecordedName != CurrentName;
/// <summary>
/// 是否为好友
/// </summary>
public bool IsFriend => SteamFriends.HasFriend(SteamId, EFriendFlags.k_EFriendFlagImmediate);
/// <summary>
/// 记录状态
/// </summary>
public PlayerRecordStatus RecordStatus { get; set; }
/// <summary>
/// 当前玩家名称(实时获取)
/// </summary>
public string CurrentName
{ {
get get
{ {
if (SteamManager.Initialized) if (SteamManager.Initialized)
{ {
return SteamFriends.GetFriendPersonaName(steamId); return SteamFriends.GetFriendPersonaName(SteamId);
}
else
{
return playerNameRecorded ?? "";
} }
return RecordedName ?? string.Empty;
} }
} }
// 玩家名字,一般来说是曾用名
public string playerNameRecorded; #endregion
public bool displayNameRecorded = false;
public bool HasRecordedName => !string.IsNullOrEmpty(playerNameRecorded) && playerNameRecorded != PlayerName; #region
public CSteamID steamId;
public bool IsFriend => SteamFriends.HasFriend(steamId, EFriendFlags.k_EFriendFlagImmediate); /// <summary>
public PlayerRecordStatus RecordStatus = PlayerRecordStatus.NotRecorded; /// 使用Steam ID和名称构造
/// </summary>
public PlayerInfo(CSteamID steamId, string playerName) public PlayerInfo(CSteamID steamId, string playerName)
{ {
this.steamId = steamId; SteamId = steamId;
this.playerNameRecorded = playerName; RecordedName = playerName;
} }
/// <summary>
/// 使用Steam ID构造自动获取名称
/// </summary>
public PlayerInfo(CSteamID steamId) public PlayerInfo(CSteamID steamId)
{ {
this.steamId = steamId; SteamId = steamId;
playerNameRecorded = SteamFriends.GetFriendPersonaName(steamId); RecordedName = SteamFriends.GetFriendPersonaName(steamId);
} }
/// <summary>
/// 使用字符串构造(格式: "SteamID 玩家名称"
/// </summary>
public PlayerInfo(string playerInfo) public PlayerInfo(string playerInfo)
{ {
if (string.IsNullOrEmpty(playerInfo)) if (string.IsNullOrEmpty(playerInfo))
{ {
steamId = CSteamID.Nil; SteamId = CSteamID.Nil;
playerNameRecorded = string.Empty; RecordedName = string.Empty;
return; return;
} }
Parse(playerInfo, out ulong steamIdValue, out string playerNameValue); Parse(playerInfo, out ulong steamIdValue, out string playerNameValue);
steamId = new CSteamID(steamIdValue); SteamId = new CSteamID(steamIdValue);
playerNameRecorded = playerNameValue; RecordedName = playerNameValue;
// 会因为初始化过早而导致插件初始化失败
// playerName = SteamFriends.GetFriendPersonaName(steamId);
} }
public static void Parse(string input, out ulong steamID, out string playerName)
#endregion
#region
/// <summary>
/// 解析玩家信息字符串
/// </summary>
public static void Parse(string input, out ulong steamId, out string playerName)
{ {
// 查找第一个空格作为分隔符
int spaceIndex = input.IndexOf(' '); int spaceIndex = input.IndexOf(' ');
if (spaceIndex == -1) if (spaceIndex == -1)
{ {
// 没有空格的情况整个字符串作为SteamID steamId = ulong.Parse(input);
steamID = ulong.Parse(input);
playerName = string.Empty; playerName = string.Empty;
return; return;
} }
// 分割SteamID和玩家名字
string steamIdPart = input.Substring(0, spaceIndex); string steamIdPart = input.Substring(0, spaceIndex);
string namePart = input.Substring(spaceIndex + 1); string namePart = input.Substring(spaceIndex + 1);
steamID = ulong.Parse(steamIdPart); steamId = ulong.Parse(steamIdPart);
playerName = namePart; // 保留名字原始格式(含标签和空格) playerName = namePart;
} }
public static bool TryParse(string input, out ulong steamID, out string playerName) /// <summary>
/// 尝试解析玩家信息字符串
/// </summary>
public static bool TryParse(string input, out ulong steamId, out string playerName)
{ {
steamID = 0; steamId = 0;
playerName = string.Empty; playerName = string.Empty;
try try
{ {
Parse(input, out steamID, out playerName); Parse(input, out steamId, out playerName);
return true; return true;
} }
catch catch
@@ -90,48 +140,82 @@ public class PlayerInfo : IEquatable<PlayerInfo>
return false; return false;
} }
} }
public void
MergeWith(PlayerInfo other) #endregion
#region
/// <summary>
/// 合并另一个玩家信息
/// </summary>
public void MergeWith(PlayerInfo other)
{ {
if (other is null) return; if (other is null) return;
if (steamId != other.steamId) if (SteamId != other.SteamId)
{ {
Debug.LogWarning($"Attempting to merge PlayerInfo with different steam IDs: {steamId} and {other.steamId}"); Debug.LogWarning($"尝试合并不同Steam ID的玩家信息: {SteamId} {other.SteamId}");
return; return;
} }
if (!string.IsNullOrEmpty(other.playerNameRecorded)) if (!string.IsNullOrEmpty(other.RecordedName))
{ {
playerNameRecorded = other.playerNameRecorded; RecordedName = other.RecordedName;
} }
if (other.RecordStatus > RecordStatus) if (other.RecordStatus > RecordStatus)
{ {
RecordStatus = other.RecordStatus; RecordStatus = other.RecordStatus;
} }
} }
// 生成记录进文件用的字符串 #endregion
#region
/// <summary>
/// 转换为文件存储格式
/// </summary>
public string ToInfoString() public string ToInfoString()
{ {
if (!string.IsNullOrEmpty(playerNameRecorded)) if (!string.IsNullOrEmpty(RecordedName))
{ {
return $"{steamId} {playerNameRecorded}"; return $"{SteamId} {RecordedName}";
} }
return $"{steamId} {PlayerName}"; return $"{SteamId} {CurrentName}";
} }
/// <summary>
/// 转换为显示字符串
/// </summary>
public override string ToString() public override string ToString()
{ {
if (displayNameRecorded && !string.IsNullOrEmpty(playerNameRecorded)) if (DisplayNameRecorded && !string.IsNullOrEmpty(RecordedName))
{ {
return $"{steamId} {playerNameRecorded}"; return $"{SteamId} {RecordedName}";
} }
return $"{steamId} {PlayerName}"; return $"{SteamId} {CurrentName}";
} }
public bool Equals(PlayerInfo toCompare) #endregion
=> toCompare is not null && steamId == toCompare.steamId;
#region
/// <summary>
/// 判断是否与另一个玩家信息相等
/// </summary>
public bool Equals(PlayerInfo other)
{
return other is not null && SteamId == other.SteamId;
}
/// <summary>
/// 获取哈希码
/// </summary>
public override int GetHashCode() public override int GetHashCode()
=> steamId.GetHashCode(); {
} return SteamId.GetHashCode();
}
#endregion
}

View File

@@ -5,248 +5,473 @@ using UnityEngine;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
static public class PlayerManagerGUI /// <summary>
/// 玩家管理器GUI类
/// 负责绘制大厅信息界面
/// </summary>
public static class PlayerManagerGUI
{ {
static public bool showLobbyInfo = false; #region GUI状态
static Vector2 scrollPosition = Vector2.zero;
static float offsetX; /// <summary>
static float offsetY; /// 是否显示大厅信息界面
static float lineHeight; /// </summary>
static float lineIndex; public static bool ShowLobbyInfo { get; set; }
static GUIStyle TitleGuiStyle => new(GUI.skin.label) { fontSize = 22, };
static GUIStyle CommonButtonGuiStyle => new(GUI.skin.button) { fontSize = 22, richText = false }; /// <summary>
static GUIStyle YellowButtonGuiStyle => new(GUI.skin.button) { fontSize = 22, normal = { textColor = Color.yellow, }, richText = false }; /// 滚动位置
static GUIStyle RedButtonGuiStyle => new(GUI.skin.button) { fontSize = 22, normal = { textColor = Color.red, }, richText = false }; /// </summary>
static GUIStyle GreenButtonGuiStyle => new(GUI.skin.button) { fontSize = 22, normal = { textColor = Color.green, }, richText = false }; private static Vector2 ScrollPosition;
static void InitOffset()
/// <summary>
/// GUI布局参数
/// </summary>
private static float OffsetX;
private static float OffsetY;
private static float LineHeight;
private static float LineIndex;
#endregion
#region GUIStyle缓存
/// <summary>
/// 标题样式
/// </summary>
private static GUIStyle TitleStyle;
/// <summary>
/// 普通按钮样式
/// </summary>
private static GUIStyle CommonButtonStyle;
/// <summary>
/// 黄色按钮样式(已记录玩家)
/// </summary>
private static GUIStyle YellowButtonStyle;
/// <summary>
/// 红色按钮样式(黑名单玩家)
/// </summary>
private static GUIStyle RedButtonStyle;
/// <summary>
/// 绿色按钮样式(好友)
/// </summary>
private static GUIStyle GreenButtonStyle;
/// <summary>
/// 样式是否已初始化
/// </summary>
private static bool StylesInitialized;
#endregion
#region
/// <summary>
/// 初始化布局参数
/// </summary>
private static void InitializeLayout()
{ {
offsetX = 10f; OffsetX = 10f;
offsetY = 20f; OffsetY = 20f;
lineHeight = 50f; LineHeight = 50f;
lineIndex = 0f; LineIndex = 0f;
} }
static float GetNewYPos()
/// <summary>
/// 初始化GUIStyle
/// </summary>
private static void InitializeStyles()
{ {
return offsetY + lineHeight * ++lineIndex; if (StylesInitialized) return;
TitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 22 };
CommonButtonStyle = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
YellowButtonStyle = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
YellowButtonStyle.normal.textColor = Color.yellow;
RedButtonStyle = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
RedButtonStyle.normal.textColor = Color.red;
GreenButtonStyle = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
GreenButtonStyle.normal.textColor = Color.green;
StylesInitialized = true;
} }
static float GetCurYPos()
#endregion
#region
/// <summary>
/// 获取新行的Y坐标
/// </summary>
private static float GetNewYPosition()
{ {
return offsetY + lineHeight * lineIndex; return OffsetY + LineHeight * ++LineIndex;
} }
static GUIStyle GetButtonStyle(PlayerInfo playerInfo)
/// <summary>
/// 获取当前行的Y坐标
/// </summary>
private static float GetCurrentYPosition()
{ {
if (playerInfo.RecordStatus == PlayerRecordStatus.Blacklisted) return OffsetY + LineHeight * LineIndex;
{
return RedButtonGuiStyle;
}
else if (playerInfo.RecordStatus == PlayerRecordStatus.Recorded)
{
return YellowButtonGuiStyle;
}
else if (playerInfo.IsFriend)
{
return GreenButtonGuiStyle;
}
else
{
return CommonButtonGuiStyle;
}
} }
static string GetRecordedNameButtonText(PlayerInfo playerInfo)
=> playerInfo.displayNameRecorded switch #endregion
#region
/// <summary>
/// 根据玩家状态获取按钮样式
/// </summary>
private static GUIStyle GetButtonStyle(PlayerInfo player)
{
return player.RecordStatus switch
{ {
true => "显示现用名", PlayerRecordStatus.Blacklisted => RedButtonStyle,
false => "显示曾用名", PlayerRecordStatus.Recorded => YellowButtonStyle,
_ => player.IsFriend ? GreenButtonStyle : CommonButtonStyle
}; };
static string GetRecordButtonText(PlayerInfo playerInfo) }
=> playerInfo.RecordStatus switch
/// <summary>
/// 获取记录按钮文本
/// </summary>
private static string GetRecordButtonText(PlayerInfo player)
{
return player.RecordStatus switch
{ {
PlayerRecordStatus.NotRecorded => "记录", PlayerRecordStatus.NotRecorded => "记录",
PlayerRecordStatus.Recorded => "拉黑", PlayerRecordStatus.Recorded => "拉黑",
PlayerRecordStatus.Blacklisted => "取消", PlayerRecordStatus.Blacklisted => "取消",
_ => "未知状态", _ => "未知状态"
}; };
static void OnRecordButtonClick(PlayerInfo playerInfo)
{
//Debug.Log($"RecordStatus {playerInfo.RecordStatus}");
switch (playerInfo.RecordStatus)
{
case PlayerRecordStatus.NotRecorded:
PlayerManager.playerRecoder.RecordPlayer(playerInfo);
break;
case PlayerRecordStatus.Recorded:
PlayerManager.blackListRecorder.RecordPlayer(playerInfo);
break;
case PlayerRecordStatus.Blacklisted:
PlayerManager.playerRecoder.CancelRecordPlayer(playerInfo);
PlayerManager.blackListRecorder.CancelRecordPlayer(playerInfo);
break;
default:
Debug.LogError($"未知的记录状态: {playerInfo.RecordStatus}");
return;
}
//Debug.Log($"RecordStatus {playerInfo.RecordStatus}");
PlayerManager.OnLobbyMembersChanged();
} }
/// <summary>
static void DrawCurrentLobbyInfo() /// 获取曾用名切换按钮文本
/// </summary>
private static string GetNameToggleButtonText(PlayerInfo player)
{ {
// 当前Lobby信息 return player.DisplayNameRecorded ? "显示现用名" : "显示曾用名";
GUI.Label
(
new Rect(offsetX, GetNewYPos(), 2000f, 40f),
$"当前Lobby:{PlayerManager.CurrentLobbyID} 房主:{PlayerManager.LobbyOwner.PlayerName}",
TitleGuiStyle
);
for (int i = 0; i < PlayerManager.lobbyMemberList.Count; i++)
{
PlayerInfo lobbyMember = PlayerManager.lobbyMemberList[i];
if (lobbyMember.steamId.IsValid())
{
// 玩家信息
if (GUI.Button
(
new Rect(offsetX, GetNewYPos(), 500f, 40f),
lobbyMember.ToString(),
GetButtonStyle(lobbyMember)
)
)
{
GUIUtility.systemCopyBuffer = lobbyMember.ToString();
SteamFriends.ActivateGameOverlayToUser("steamid", lobbyMember.steamId);
}
// 记录按钮
if (GUI.Button
(
new Rect(520f, GetCurYPos(), 100f, 40f),
GetRecordButtonText(lobbyMember),
CommonButtonGuiStyle
)
)
{
OnRecordButtonClick(lobbyMember);
}
// 踢人按钮
if (!PlayerManager.IsHost(lobbyMember) && !PlayerManager.IsLocalPlayer(lobbyMember))
{
if (GUI.Button
(
new Rect(630f, GetCurYPos(), 100f, 40f),
"Kick!",
CommonButtonGuiStyle
)
)
{
PlayerManager.KickPlayer(lobbyMember.steamId, MultiplayerManager.KickResponse.DidNotRecievePackages);
}
}
// 曾用名按钮
if (lobbyMember.HasRecordedName)
{
if (GUI.Button
(
new Rect(740f, GetCurYPos(), 120f, 40f),
GetRecordedNameButtonText(lobbyMember),
CommonButtonGuiStyle
)
)
{
lobbyMember.displayNameRecorded = !lobbyMember.displayNameRecorded;
}
}
}
}
} }
static void DrawLobbySearchInfo()
/// <summary>
/// 获取房间公开性文本
/// </summary>
private static string GetRoomPublicText()
{ {
string areaStr = ""; return MatchmakingHandler.LobbyType == ELobbyType.k_ELobbyTypePublic ? "公开" : "好友可见";
switch (PlayerManager.elobbyDistanceFilter) }
#endregion
#region
/// <summary>
/// 处理房间公开切换按钮点击
/// </summary>
private static void OnRoomPublicToggleButtonClick()
{
if (MultiplayerManager.IsServer)
{ {
case ELobbyDistanceFilter.k_ELobbyDistanceFilterClose: if (MatchmakingHandler.LobbyType == ELobbyType.k_ELobbyTypePublic)
areaStr = "当前地区";
break;
case ELobbyDistanceFilter.k_ELobbyDistanceFilterDefault:
areaStr = "当前及附近地区";
break;
case ELobbyDistanceFilter.k_ELobbyDistanceFilterFar:
areaStr = "半球范围内";
break;
case ELobbyDistanceFilter.k_ELobbyDistanceFilterWorldwide:
areaStr = "世界";
break;
}
if (GUI.Button
(
new Rect(offsetX, GetNewYPos(), 400f, 40f),
$"搜索范围 {areaStr}",
CommonButtonGuiStyle
)
)
{
if (PlayerManager.elobbyDistanceFilter == ELobbyDistanceFilter.k_ELobbyDistanceFilterWorldwide)
{ {
PlayerManager.elobbyDistanceFilter = ELobbyDistanceFilter.k_ELobbyDistanceFilterClose; MatchmakingHandler.SetNewLobbyType(ELobbyType.k_ELobbyTypeFriendsOnly);
} }
else else
{ {
PlayerManager.elobbyDistanceFilter++; MatchmakingHandler.SetNewLobbyType(ELobbyType.k_ELobbyTypePublic);
} }
} }
if (GUI.Button }
(
new Rect(offsetX + 420f, GetCurYPos(), 100f, 40f), /// <summary>
"刷新", /// 处理记录按钮点击
CommonButtonGuiStyle /// </summary>
) private static void OnRecordButtonClick(PlayerInfo player)
) {
switch (player.RecordStatus)
{ {
PlayerManager.RequestLobbyList(); case PlayerRecordStatus.NotRecorded:
PlayerManager.playerRecorder.RecordPlayer(player);
break;
case PlayerRecordStatus.Recorded:
PlayerManager.blackListRecorder.RecordPlayer(player);
break;
case PlayerRecordStatus.Blacklisted:
PlayerManager.playerRecorder.CancelRecordPlayer(player);
PlayerManager.blackListRecorder.CancelRecordPlayer(player);
break;
default:
Debug.LogError($"未知的记录状态: {player.RecordStatus}");
return;
} }
PlayerManager.OnLobbyMembersChanged();
}
/// <summary>
/// 处理踢人按钮点击
/// </summary>
private static void OnKickButtonClick(PlayerInfo player)
{
PlayerManager.KickPlayer(player.SteamId, MultiplayerManager.KickResponse.DidNotRecievePackages);
}
/// <summary>
/// 处理曾用名切换按钮点击
/// </summary>
private static void OnNameToggleButtonClick(PlayerInfo player)
{
player.DisplayNameRecorded = !player.DisplayNameRecorded;
}
/// <summary>
/// 处理切换大厅距离过滤按钮点击
/// </summary>
private static void OnCycleLobbyDistanceFilterClick()
{
CycleLobbyDistanceFilter();
}
/// <summary>
/// 处理刷新大厅列表按钮点击
/// </summary>
private static void OnRefreshLobbyListClick()
{
PlayerManager.RequestLobbyList();
}
#endregion
#region GUI绘制方法
/// <summary>
/// 绘制当前大厅信息
/// </summary>
private static void DrawCurrentLobbyInfo()
{
InitializeStyles();
// 绘制大厅标题
GUI.Label( GUI.Label(
new Rect(offsetX, GetNewYPos(), 500f, 40f), new Rect(OffsetX, GetNewYPosition(), 2000f, 40f),
$"搜索到Lobby数量{PlayerManager.lobbyDataList.Count}", $"当前大厅: {PlayerManager.CurrentLobbyID} 房主: {PlayerManager.LobbyOwner.CurrentName}",
TitleGuiStyle TitleStyle
); );
scrollPosition = GUI.BeginScrollView(
new Rect(offsetX, GetNewYPos(), 525f, 405f), // 绘制房间公开性
scrollPosition, GUI.Label(
new Rect(0f, 0f, 505f, PlayerManager.lobbyDataList.Count * 50) new Rect(OffsetX, GetNewYPosition(), 500f, 40f),
); $"房间类型: {GetRoomPublicText()}",
for (int j = 0; j < PlayerManager.lobbyDataList.Count; j++) TitleStyle
);
// 绘制房间公开切换按钮
if (GUI.Button(
new Rect(OffsetX + 520f, GetCurrentYPosition(), 100f, 40f),
"切换公开性",
CommonButtonStyle
))
{ {
CSteamID lobbyData = PlayerManager.lobbyDataList[j]; OnRoomPublicToggleButtonClick();
if (GUI.Button }
(
new Rect(offsetX, lineHeight * j, 500f, 40f), // 绘制大厅成员列表
$"{lobbyData} 人数{SteamMatchmaking.GetNumLobbyMembers(lobbyData)}", for (int i = 0; i < PlayerManager.lobbyMemberList.Count; i++)
CommonButtonGuiStyle {
) PlayerInfo member = PlayerManager.lobbyMemberList[i];
) if (!member.SteamId.IsValid()) continue;
DrawPlayerRow(member);
}
}
/// <summary>
/// 绘制单个玩家行
/// </summary>
private static void DrawPlayerRow(PlayerInfo player)
{
// 玩家信息按钮
if (GUI.Button(
new Rect(OffsetX, GetNewYPosition(), 500f, 40f),
player.ToString(),
GetButtonStyle(player)
))
{
GUIUtility.systemCopyBuffer = player.ToString();
SteamFriends.ActivateGameOverlayToUser("steamid", player.SteamId);
}
// 记录按钮
if (GUI.Button(
new Rect(520f, GetCurrentYPosition(), 100f, 40f),
GetRecordButtonText(player),
CommonButtonStyle
))
{
OnRecordButtonClick(player);
}
// 踢人按钮(非房主且非本地玩家)
if (!PlayerManager.IsHost(player) && !PlayerManager.IsLocalPlayer(player))
{
if (GUI.Button(
new Rect(630f, GetCurrentYPosition(), 100f, 40f),
"Kick!",
CommonButtonStyle
))
{ {
PlayerManager.JoinSpecificServer(lobbyData); OnKickButtonClick(player);
} }
} }
// 曾用名切换按钮
if (player.HasRecordedName)
{
if (GUI.Button(
new Rect(740f, GetCurrentYPosition(), 120f, 40f),
GetNameToggleButtonText(player),
CommonButtonStyle
))
{
OnNameToggleButtonClick(player);
}
}
}
/// <summary>
/// 绘制大厅搜索信息
/// </summary>
private static void DrawLobbySearchInfo()
{
InitializeStyles();
// 搜索范围按钮
string areaText = GetAreaFilterText();
if (GUI.Button(
new Rect(OffsetX, GetNewYPosition(), 400f, 40f),
$"搜索范围 {areaText}",
CommonButtonStyle
))
{
OnCycleLobbyDistanceFilterClick();
}
// 刷新按钮
if (GUI.Button(
new Rect(OffsetX + 420f, GetCurrentYPosition(), 100f, 40f),
"刷新",
CommonButtonStyle
))
{
OnRefreshLobbyListClick();
}
// 搜索结果数量
GUI.Label(
new Rect(OffsetX, GetNewYPosition(), 500f, 40f),
$"搜索到大厅数量: {PlayerManager.lobbyDataList.Count}",
TitleStyle
);
// 大厅列表滚动视图
DrawLobbyListScrollView();
}
/// <summary>
/// 获取区域过滤文本
/// </summary>
private static string GetAreaFilterText()
{
return PlayerManager.lobbyDistanceFilter switch
{
ELobbyDistanceFilter.k_ELobbyDistanceFilterClose => "当前地区",
ELobbyDistanceFilter.k_ELobbyDistanceFilterDefault => "当前及附近地区",
ELobbyDistanceFilter.k_ELobbyDistanceFilterFar => "半球范围内",
ELobbyDistanceFilter.k_ELobbyDistanceFilterWorldwide => "世界",
_ => "未知"
};
}
/// <summary>
/// 循环切换大厅距离过滤
/// </summary>
private static void CycleLobbyDistanceFilter()
{
PlayerManager.lobbyDistanceFilter = PlayerManager.lobbyDistanceFilter switch
{
ELobbyDistanceFilter.k_ELobbyDistanceFilterWorldwide => ELobbyDistanceFilter.k_ELobbyDistanceFilterClose,
_ => PlayerManager.lobbyDistanceFilter + 1
};
}
/// <summary>
/// 绘制大厅列表滚动视图
/// </summary>
private static void DrawLobbyListScrollView()
{
ScrollPosition = GUI.BeginScrollView(
new Rect(OffsetX, GetNewYPosition(), 525f, 405f),
ScrollPosition,
new Rect(0f, 0f, 505f, PlayerManager.lobbyDataList.Count * 50f)
);
for (int i = 0; i < PlayerManager.lobbyDataList.Count; i++)
{
CSteamID lobbyId = PlayerManager.lobbyDataList[i];
if (GUI.Button(
new Rect(OffsetX, LineHeight * i, 500f, 40f),
$"{lobbyId} 人数{SteamMatchmaking.GetNumLobbyMembers(lobbyId)}",
CommonButtonStyle
))
{
PlayerManager.JoinSpecificServer(lobbyId);
}
}
GUI.EndScrollView(); GUI.EndScrollView();
} }
static public void DrawLobbyInfoGUI()
{
InitOffset();
if (PlayerManager.IMatchmakingHandler.IsInsideLobby) #endregion
#region
/// <summary>
/// 绘制大厅信息GUI
/// </summary>
public static void DrawLobbyInfoGUI()
{
InitializeLayout();
InitializeStyles();
if (PlayerManager.MatchmakingHandler.IsInsideLobby)
{ {
DrawCurrentLobbyInfo(); DrawCurrentLobbyInfo();
} }
else else
{ {
GUI.Label( GUI.Label(
new Rect(offsetX, GetNewYPos(), 500f, 40f), new Rect(OffsetX, GetNewYPosition(), 500f, 40f),
"当前不在Lobby中", "当前不在大厅中",
TitleGuiStyle TitleStyle
); );
} }
DrawLobbySearchInfo(); DrawLobbySearchInfo();
} }
}
#endregion
}

View File

@@ -1,7 +1,10 @@
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
public class PlayerRecoder : RecoderBase /// <summary>
/// 玩家记录器,用于记录普通玩家信息
/// </summary>
public class PlayerRecorder : RecorderBase
{ {
protected override string FileName => "CustomPlayerRecord.txt"; protected override string FileName => "CustomPlayerRecord.txt";
protected override PlayerRecordStatus RecordStatus => PlayerRecordStatus.Recorded; protected override PlayerRecordStatus RecordStatus => PlayerRecordStatus.Recorded;
} }

299
Plugin.cs
View File

@@ -6,124 +6,303 @@ using UnityEngine;
using Steamworks; using Steamworks;
using HarmonyLib; using HarmonyLib;
using System.Data; using System.Data;
using System.Reflection;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
/// <summary>
/// 玩家管理器插件主类
/// 提供大厅信息显示、玩家记录、黑名单管理等功能
/// </summary>
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)] [BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
[BepInProcess("StickFight.exe")] [BepInProcess("StickFight.exe")]
public class PlayerManager : BaseUnityPlugin public class PlayerManager : BaseUnityPlugin
{ {
static ConfigEntry<KeyCode> lobbyInfoToggleKeyCode; #region
static ConfigEntry<bool> blockKicks;
static CallResult<LobbyMatchList_t> onLobbyMatchListCallResult = CallResult<LobbyMatchList_t>.Create(null); /// <summary>
public static List<CSteamID> lobbyDataList = []; /// 是否屏蔽来自其他玩家的踢出请求
public static ELobbyDistanceFilter elobbyDistanceFilter = ELobbyDistanceFilter.k_ELobbyDistanceFilterDefault; /// </summary>
public static PlayerRecoder playerRecoder = new(); private static ConfigEntry<bool> _blockKicks;
/// <summary>
/// 大厅信息开关快捷键
/// </summary>
private static ConfigEntry<KeyCode> _lobbyInfoToggleKeyCode;
#endregion
#region Steam回调
/// <summary>
/// 大厅列表查询回调
/// </summary>
private static readonly CallResult<LobbyMatchList_t> LobbyMatchListCallResult = CallResult<LobbyMatchList_t>.Create(null);
#endregion
#region
/// <summary>
/// 玩家记录器实例
/// </summary>
public static PlayerRecorder playerRecorder = new();
/// <summary>
/// 黑名单记录器实例
/// </summary>
public static BlackListRecorder blackListRecorder = new(); public static BlackListRecorder blackListRecorder = new();
/// <summary>
/// 当前大厅成员列表
/// </summary>
public static List<PlayerInfo> lobbyMemberList = []; public static List<PlayerInfo> lobbyMemberList = [];
public static MatchmakingHandler IMatchmakingHandler => MatchmakingHandler.Instance; /// <summary>
public static CSteamID CurrentLobbyID => IMatchmakingHandler.CurrentLobby; /// 搜索到的大厅列表
public static PlayerInfo LobbyOwner => new(SteamMatchmaking.GetLobbyOwner(CurrentLobbyID));// PlayerManager.cs 新增代码 /// </summary>
public static PlayerInfo LocalPlayer => new(SteamUser.GetSteamID()); public static List<CSteamID> lobbyDataList = [];
public static object lobbyMemberLock = new(); // 线程同步锁
void Awake() /// <summary>
/// 大厅距离过滤设置
/// </summary>
public static ELobbyDistanceFilter lobbyDistanceFilter = ELobbyDistanceFilter.k_ELobbyDistanceFilterDefault;
/// <summary>
/// 线程同步锁,保护大厅成员列表操作
/// </summary>
private static readonly object LobbyMemberLock = new();
#endregion
#region 访
/// <summary>
/// 大厅管理器实例
/// </summary>
public static MatchmakingHandler MatchmakingHandler => MatchmakingHandler.Instance;
/// <summary>
/// 当前大厅ID
/// </summary>
public static CSteamID CurrentLobbyID => MatchmakingHandler.CurrentLobby;
/// <summary>
/// 大厅房主信息
/// </summary>
public static PlayerInfo LobbyOwner => new(SteamMatchmaking.GetLobbyOwner(CurrentLobbyID));
/// <summary>
/// 本地玩家信息
/// </summary>
public static PlayerInfo LocalPlayer => new(SteamUser.GetSteamID());
#endregion
#region
private void Awake()
{ {
// 绑定配置 InitializeConfiguration();
blockKicks = Config.Bind<bool>("General", "BlockKicks", true, "是否屏蔽来自其他玩家的踢出请求"); ApplyPatches();
lobbyInfoToggleKeyCode = Config.Bind<KeyCode>("LobbyInfo", "ToggleKeyCode", KeyCode.F2, "Lobby信息开关快捷键"); }
// patch
if (blockKicks.Value) private void Update()
{
HandleInput();
}
private void OnGUI()
{
if (PlayerManagerGUI.ShowLobbyInfo)
{
PlayerManagerGUI.DrawLobbyInfoGUI();
}
}
#endregion
#region
/// <summary>
/// 初始化配置项
/// </summary>
private void InitializeConfiguration()
{
_blockKicks = Config.Bind(
"General",
"BlockKicks",
true,
"是否屏蔽来自其他玩家的踢出请求"
);
_lobbyInfoToggleKeyCode = Config.Bind(
"LobbyInfo",
"ToggleKeyCode",
KeyCode.F2,
"Lobby信息开关快捷键"
);
}
/// <summary>
/// 应用Harmony补丁
/// </summary>
private void ApplyPatches()
{
if (_blockKicks.Value)
{ {
Harmony.CreateAndPatchAll(typeof(IgnoreKick)); Harmony.CreateAndPatchAll(typeof(IgnoreKick));
} }
Harmony.CreateAndPatchAll(typeof(ApplyBlacklist)); Harmony.CreateAndPatchAll(typeof(ApplyBlacklist));
Harmony.CreateAndPatchAll(typeof(ListenForChange)); Harmony.CreateAndPatchAll(typeof(ListenForChange));
} }
void Update()
/// <summary>
/// 处理输入
/// </summary>
private void HandleInput()
{ {
if (Input.GetKeyDown(lobbyInfoToggleKeyCode.Value)) if (Input.GetKeyDown(_lobbyInfoToggleKeyCode.Value))
{ {
if (PlayerManagerGUI.showLobbyInfo) ToggleLobbyInfoGUI();
{
PlayerManagerGUI.showLobbyInfo = false;
Debug.Log("The GUI is closed");
}
else
{
RequestLobbyList();
PlayerManagerGUI.showLobbyInfo = true;
Debug.Log("The GUI is opened");
}
} }
} }
void OnGUI()
/// <summary>
/// 切换大厅信息GUI显示状态
/// </summary>
private void ToggleLobbyInfoGUI()
{ {
if (PlayerManagerGUI.showLobbyInfo) if (PlayerManagerGUI.ShowLobbyInfo)
{ {
PlayerManagerGUI.DrawLobbyInfoGUI(); PlayerManagerGUI.ShowLobbyInfo = false;
Debug.Log("大厅信息界面已关闭");
}
else
{
RequestLobbyList();
PlayerManagerGUI.ShowLobbyInfo = true;
Debug.Log("大厅信息界面已打开");
} }
} }
#endregion
#region
/// <summary>
/// 判断指定玩家是否为房主
/// </summary>
/// <param name="player">要检查的玩家</param>
/// <returns>如果是房主返回true</returns>
public static bool IsHost(PlayerInfo player) public static bool IsHost(PlayerInfo player)
{ {
return player.Equals(LobbyOwner); return player.Equals(LobbyOwner);
} }
/// <summary>
/// 判断指定玩家是否为本地玩家
/// </summary>
/// <param name="player">要检查的玩家</param>
/// <returns>如果是本地玩家返回true</returns>
public static bool IsLocalPlayer(PlayerInfo player) public static bool IsLocalPlayer(PlayerInfo player)
{ {
return player.Equals(LocalPlayer); return player.Equals(LocalPlayer);
} }
/// <summary>
/// 请求大厅列表
/// </summary>
public static void RequestLobbyList() public static void RequestLobbyList()
{ {
SteamMatchmaking.AddRequestLobbyListDistanceFilter(elobbyDistanceFilter); SteamMatchmaking.AddRequestLobbyListDistanceFilter(lobbyDistanceFilter);
SteamAPICall_t hAPICall2 = SteamMatchmaking.RequestLobbyList(); SteamAPICall_t apiCall = SteamMatchmaking.RequestLobbyList();
onLobbyMatchListCallResult.Set(hAPICall2, new CallResult<LobbyMatchList_t>.APIDispatchDelegate(OnRequestLobbyList)); LobbyMatchListCallResult.Set(apiCall, OnLobbyMatchListReceived);
} }
static void OnRequestLobbyList(LobbyMatchList_t param, bool bIOFailure)
/// <summary>
/// 处理大厅列表查询结果
/// </summary>
private static void OnLobbyMatchListReceived(LobbyMatchList_t result, bool ioFailure)
{ {
lobbyDataList = []; lobbyDataList = [];
uint nLobbiesMatching = param.m_nLobbiesMatching; uint lobbyCount = result.m_nLobbiesMatching;
for (int i = 0; i < nLobbiesMatching; i++)
for (uint i = 0; i < lobbyCount; i++)
{ {
CSteamID lobbyByIndex = SteamMatchmaking.GetLobbyByIndex(i); CSteamID lobbyId = SteamMatchmaking.GetLobbyByIndex((int)i);
if (SteamMatchmaking.GetLobbyData(lobbyByIndex, StickFightConstants.VERSION_KEY) == StickFightConstants.VERSION_VALUE) if (SteamMatchmaking.GetLobbyData(lobbyId, StickFightConstants.VERSION_KEY) == StickFightConstants.VERSION_VALUE)
{ {
lobbyDataList.Add(lobbyByIndex); lobbyDataList.Add(lobbyId);
} }
} }
} }
/// <summary>
/// 加入指定大厅
/// </summary>
/// <param name="lobby">要加入的大厅ID</param>
public static void JoinSpecificServer(CSteamID lobby) public static void JoinSpecificServer(CSteamID lobby)
{ {
Traverse.Create(MatchmakingHandler.Instance).Field("mCurrentConnectionType").SetValue(ConnectionType.Specific); Traverse.Create(MatchmakingHandler.Instance)
.Field("mCurrentConnectionType")
.SetValue(ConnectionType.Specific);
MatchmakingHandler.Instance.JoinServer(lobby, GameManager.Instance.mMultiplayerManager.OnServerJoined); MatchmakingHandler.Instance.JoinServer(lobby, GameManager.Instance.mMultiplayerManager.OnServerJoined);
OnLobbyMembersChanged(); OnLobbyMembersChanged();
} }
public static void KickPlayer(CSteamID clientID, MultiplayerManager.KickResponse response)
/// <summary>
/// 踢出指定玩家
/// </summary>
/// <param name="clientId">被踢玩家的Steam ID</param>
/// <param name="response">踢出原因响应</param>
public static void KickPlayer(CSteamID clientId, MultiplayerManager.KickResponse response)
{ {
byte[] data = [(byte)response]; byte[] data = [(byte)response];
P2PPackageHandler.Instance.SendP2PPacketToUser(clientID, data, P2PPackageHandler.MsgType.KickPlayer, EP2PSend.k_EP2PSendReliable, 0); P2PPackageHandler.Instance.SendP2PPacketToUser(
clientId,
data,
P2PPackageHandler.MsgType.KickPlayer,
EP2PSend.k_EP2PSendReliable,
0
);
} }
/// <summary>
/// 当大厅成员变化时更新成员列表
/// </summary>
public static void OnLobbyMembersChanged() public static void OnLobbyMembersChanged()
{ {
Debug.Log("OnLobbyMembersChanged被触发"); Debug.Log("大厅成员列表已更新");
// 使用锁保护集合操作
lock (lobbyMemberLock) lock (LobbyMemberLock)
{ {
// 清空当前成员列表
lobbyMemberList.Clear(); lobbyMemberList.Clear();
// 重新构建当前成员列表 int memberLimit = SteamMatchmaking.GetLobbyMemberLimit(CurrentLobbyID);
int lobbyMemberLimit = SteamMatchmaking.GetLobbyMemberLimit(CurrentLobbyID); for (int i = 0; i < memberLimit; i++)
for (int i = 0; i < lobbyMemberLimit; i++)
{ {
PlayerInfo lobbyMember = new(SteamMatchmaking.GetLobbyMemberByIndex(CurrentLobbyID, i)); PlayerInfo member = new(SteamMatchmaking.GetLobbyMemberByIndex(CurrentLobbyID, i));
// 合并记录状态
// 注意这里先合并普通记录再合并黑名单的顺序是必要的 // 合并记录状态:先合并普通记录再合并黑名单
// 因为黑名单记录的曾用名必然比普通记录的曾用名更新 // 黑名单的曾用名比普通记录的曾用名更新
playerRecoder.CheckAndMerge(lobbyMember); playerRecorder.CheckAndMerge(member);
blackListRecorder.CheckAndMerge(lobbyMember); blackListRecorder.CheckAndMerge(member);
lobbyMemberList.Add(lobbyMember);
lobbyMemberList.Add(member);
} }
} }
} }
}
#endregion
}
/// <summary>
/// Stick Fight游戏常量
/// </summary>
internal static class StickFightConstants
{
public const string VERSION_KEY = "version";
public const string VERSION_VALUE = "1.0.0";
}

View File

@@ -6,81 +6,193 @@ using UnityEngine;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
abstract public class RecoderBase /// <summary>
/// 记录器基类
/// 提供玩家记录和黑名单功能的通用实现
/// </summary>
public abstract class RecorderBase
{ {
protected List<PlayerInfo> playerList = []; #region
/// <summary>
/// 玩家信息集合(用于快速查找)
/// </summary>
protected HashSet<PlayerInfo> playerSet = new HashSet<PlayerInfo>();
/// <summary>
/// 玩家信息列表(用于有序遍历和文件写入)
/// </summary>
protected List<PlayerInfo> playerList = new List<PlayerInfo>();
#endregion
#region
/// <summary>
/// 记录文件名
/// </summary>
protected abstract string FileName { get; } protected abstract string FileName { get; }
/// <summary>
/// 记录状态类型
/// </summary>
protected abstract PlayerRecordStatus RecordStatus { get; } protected abstract PlayerRecordStatus RecordStatus { get; }
#endregion
#region
/// <summary>
/// 记录文件完整路径
/// </summary>
protected string FilePath => Path.Combine(Application.dataPath, FileName); protected string FilePath => Path.Combine(Application.dataPath, FileName);
#endregion
#region
/// <summary>
/// 构造函数,初始化时加载已有记录
/// </summary>
protected RecorderBase()
{
RefreshPlayerRecordList();
}
#endregion
#region
/// <summary>
/// 检查玩家是否已被记录
/// </summary>
public bool IsPlayerRecorded(PlayerInfo playerInfo) public bool IsPlayerRecorded(PlayerInfo playerInfo)
{ {
foreach (PlayerInfo info in playerList) return playerInfo != null && playerSet.Contains(playerInfo);
{
if (info.Equals(playerInfo)) return true;
}
return false;
} }
/// <summary>
/// 检查玩家是否已被记录通过Steam ID
/// </summary>
public bool IsPlayerRecorded(CSteamID playerId) public bool IsPlayerRecorded(CSteamID playerId)
{ {
PlayerInfo playerInfo = new(playerId); PlayerInfo playerInfo = new PlayerInfo(playerId);
foreach (PlayerInfo info in playerList) return playerSet.Contains(playerInfo);
{
if (info.Equals(playerInfo)) return true;
}
return false;
} }
/// <summary>
/// 记录玩家
/// </summary>
public void RecordPlayer(PlayerInfo playerInfo) public void RecordPlayer(PlayerInfo playerInfo)
{ {
if (playerInfo == null) return;
playerInfo.RecordStatus = RecordStatus; playerInfo.RecordStatus = RecordStatus;
playerList.Add(playerInfo);
using StreamWriter streamWriter = new(FilePath, true); if (playerSet.Add(playerInfo))
streamWriter.WriteLine(playerInfo); {
playerList.Add(playerInfo);
}
AppendToFile(playerInfo);
} }
/// <summary>
/// 取消记录玩家
/// </summary>
public void CancelRecordPlayer(PlayerInfo playerInfo)
{
if (playerInfo == null) return;
if (playerSet.Remove(playerInfo))
{
playerList.Remove(playerInfo);
WriteBackToFile();
}
}
/// <summary>
/// 检查并合并玩家记录信息
/// </summary>
public void CheckAndMerge(PlayerInfo playerInfo)
{
if (playerInfo == null) return;
foreach (PlayerInfo existingInfo in playerSet)
{
if (existingInfo.Equals(playerInfo))
{
playerInfo.MergeWith(existingInfo);
return;
}
}
}
#endregion
#region
/// <summary>
/// 刷新玩家记录列表(从文件加载)
/// </summary>
protected void RefreshPlayerRecordList() protected void RefreshPlayerRecordList()
{ {
if (!File.Exists(FilePath)) if (!File.Exists(FilePath))
{ {
return; return;
} }
playerSet.Clear();
playerList.Clear(); playerList.Clear();
using StreamReader streamReader = new(FilePath);
string item; using (StreamReader reader = new StreamReader(FilePath))
while ((item = streamReader.ReadLine()) != null)
{ {
PlayerInfo playerInfo = new(item) string line;
while ((line = reader.ReadLine()) != null)
{ {
RecordStatus = RecordStatus if (string.IsNullOrEmpty(line)) continue;
};
playerList.Add(playerInfo); PlayerInfo playerInfo = new PlayerInfo(line)
} {
} RecordStatus = RecordStatus
protected void WriteBackToFile() };
{
using StreamWriter streamWriter = new(FilePath, false); if (playerSet.Add(playerInfo))
foreach (PlayerInfo info in playerList) {
{ playerList.Add(playerInfo);
streamWriter.WriteLine(info.ToInfoString()); }
}
}
public void CancelRecordPlayer(PlayerInfo playerInfo)
{
playerList.Remove(playerInfo);
WriteBackToFile();
}
protected RecoderBase()
{
RefreshPlayerRecordList();
}
public void CheckAndMerge(PlayerInfo playerInfo)
{
if (playerInfo is null) return;
for (int i = 0; i < playerList.Count; i++)
{
if (playerList[i].Equals(playerInfo))
{
playerInfo.MergeWith(playerList[i]);
return;
} }
} }
} }
}
/// <summary>
/// 将玩家信息写入文件
/// </summary>
protected void WriteBackToFile()
{
using (StreamWriter writer = new StreamWriter(FilePath, false))
{
foreach (PlayerInfo info in playerList)
{
writer.WriteLine(info.ToInfoString());
}
}
}
#endregion
#region
/// <summary>
/// 追加玩家信息到文件
/// </summary>
private void AppendToFile(PlayerInfo playerInfo)
{
using (StreamWriter writer = new StreamWriter(FilePath, true))
{
writer.WriteLine(playerInfo);
}
}
#endregion
}

View File

@@ -4,7 +4,7 @@
<TargetFramework>net35</TargetFramework> <TargetFramework>net35</TargetFramework>
<AssemblyName>stick.plugins.playermanager</AssemblyName> <AssemblyName>stick.plugins.playermanager</AssemblyName>
<Product>PlayerManager</Product> <Product>PlayerManager</Product>
<Version>3.0.4</Version> <Version>3.1.0</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<RestoreAdditionalProjectSources> <RestoreAdditionalProjectSources>