39 Commits

Author SHA1 Message Date
Yue-bin
b19f906216 fix(PlayerManagerGUI): 修复大厅加入逻辑中的条件判断错误
修改了玩家管理GUI中大厅加入的条件判断逻辑,将内部的条件判断移到外部,
确保只有当不在大厅内时才能加入新的大厅,修复了可能的重复加入问题。

adhd???
2026-06-19 20:30:37 +08:00
Yue-bin
3ecb7fb78b fix(PlayerManager): 修复本地玩家初始化逻辑
当SteamManager已初始化但LocalPlayer为空时才创建新的LocalPlayer实例,
避免重复初始化问题。
2026-06-19 20:25:15 +08:00
Yue-bin
4e8931caba feat(gui): 添加已访问大厅的视觉标识功能
添加了ButtonPressed样式用于标识已进入过的大厅,
使用户能够直观区分未访问和已访问的大厅。

在PlayerManager中新增enteredLobbyList集合来跟踪已访问的大厅,
并在界面中根据访问状态显示不同的按钮颜色。
2026-06-19 20:24:29 +08:00
Yue-bin
3adcdc46ba Merge branch 'master' of 172.16.29.13:Stick_Mods/stick.plugins.playermanager
All checks were successful
Publish Release / build (push) Successful in 31s
2026-06-11 02:13:25 +08:00
Yue-bin
b6edc70594 chore(ci): 为.NET构建流程添加代理配置
在Gitea工作流中添加HTTP/HTTPS代理环境变量配置,
以解决网络访问问题并提高依赖包恢复速度。
2026-06-11 02:13:09 +08:00
9e7fb4d443 doc: 大厅搜索范围按钮现在可同步修改快速匹配的搜索范围
Some checks failed
Publish Release / build (push) Failing after 4m9s
2026-06-10 19:09:02 +08:00
6ab8c38109 feat(matchmaking): 大厅搜索范围按钮现在可同步修改快速匹配的搜索范围 2026-06-10 18:55:01 +08:00
9a5d149241 fix(playermanager): 应用静音Patch 2026-06-10 18:54:41 +08:00
Yue-bin
f193d8a805 fix(playermanager): 修复加好友按钮显示问题并更新版本号至4.0.5
Some checks failed
Publish Release / build (push) Failing after 3s
- 在readme.md中添加v4.0.5更新日志,记录修复了加好友按钮始终不显示的问题
- 更新stick.plugins.playermanager.csproj中的版本号从4.0.4到4.0.5
2026-05-31 01:59:19 +08:00
Yue-bin
00960eb3f7 fix(Gui): 修复添加好友按钮的本地玩家检查逻辑
- 将 PlayerManager.IsLocalPlayer(PlayerManager.LocalPlayer)
  修改为 PlayerManager.IsLocalPlayer(player)
- 确保正确检查目标玩家是否为本地玩家,而非始终检查本地玩家自己
- 避免好友列表中出现本地玩家的错误情况
2026-05-31 01:58:30 +08:00
Yue-bin
bc2d44f5cd docs(readme): 更新文档添加新功能说明
Some checks failed
Publish Release / build (push) Failing after 4m0s
- 移除了重复的玩家名称颜色显示说明
- 添加了一键加好友功能描述
- 更新了更新日志,包含v4.0.4版本的新功能:
  1. 房间列表现在在房间内也显示,但是点击无效
  2. 添加了加好友按钮
  3. 修复了禁音功能
- 调整了v4.0.3版本更新内容的序号格式
2026-05-28 17:02:17 +08:00
Yue-bin
c065b14f9d chore(PlayerManager): 更新版本号到4.0.4
- 将项目版本从4.0.3更新至4.0.4
2026-05-28 16:58:42 +08:00
Yue-bin
46f05b5489 🐞 fix(mute): 修复了禁音无效的问题
难道他真的是猪?
2026-05-28 16:58:06 +08:00
Yue-bin
3a9e903044 feat(gui): 添加加好友按钮功能
新增了加好友按钮组件,允许玩家通过Steam界面添加其他玩家为好友。
该功能包含一个可配置的按钮,点击后会复制玩家信息到剪贴板并打开Steam好友添加界面。
同时增加了相应的配置选项来控制按钮的显示。

需要配置项ShowAddFriendButton来控制加好友按钮的显示状态,
默认设置为显示(true)。
2026-05-28 16:24:23 +08:00
Yue-bin
ca331d4ac8 feat(Gui): 添加大厅搜索信息显示功能
添加了大厅搜索信息的绘制功能,并优化了服务器加入逻辑,
避免在已有大厅的情况下重复加入服务器。
2026-05-28 16:09:47 +08:00
Yue-bin
692fc25d42 fix(Gui): 修改静音按钮逻辑
- 移除主机玩家的条件限制,仅保留本地玩家检查
- 将"禁音"/"取消禁音"文本改为更标准的"静音"/"取消静音"
2026-05-08 17:42:15 +08:00
Yue-bin
bec55e4ecc fix(PlayerManagerGUI): 修复玩家管理界面中空元素导致的布局错乱问题
当widget不应该绘制时,之前会跳过列位置更新导致后续元素对齐错误,
现在添加了占位推进列位置以确保布局的一致性。
2026-05-08 17:23:16 +08:00
Yue-bin
8a36e74c4b chore(PlayerManager): 更新插件版本号从4.0.2到4.0.3
All checks were successful
Publish Release / build (push) Successful in 30s
- 将stick.plugins.playermanager.csproj中的版本号从4.0.2升级至4.0.3
- 为新版本发布做准备
2026-05-07 05:49:24 +08:00
Yue-bin
4b9c831c73 📃 docs(Readme): v4.0.3 更新日志以及接口文档 2026-05-07 05:49:16 +08:00
Yue-bin
910c407e3d feat(gui): 添加禁音按钮功能
添加了禁音按钮组件,允许用户对非主机和非本地玩家进行静音操作,
包括静音状态显示、点击事件处理等功能,并在配置中添加了是否显示
该按钮的选项。

Co-authored-by: Copilot <copilot@github.com>
2026-05-07 05:39:48 +08:00
Yue-bin
56caddcb65 refactor(Gui): 提取GUI样式管理类并重构样式引用
- 创建新的GUIStyles静态类统一管理所有GUIStyle实例
- 将原来分散在各个组件中的样式定义移到GUIStyles类中
- 移除PlayerInfoButton、PlayerManagerGUI和Widget中的重复样式代码
- 更新所有组件使用GUIStyles类的静态样式属性
- 优化样式初始化逻辑,避免重复创建GUIStyle实例
2026-05-07 05:29:07 +08:00
Yue-bin
c8abc7c265 feat(Recorder): 将玩家信息存储从HashSet改为Dictionary以实现O(1)查找
将RecorderBase中的数据结构从HashSet和List改为Dictionary<CSteamID, PlayerInfo>,
实现了O(1)时间复杂度的玩家信息查找操作,提高了IsPlayerRecorded、AddPlayerRecord等
核心方法的性能。同时优化了CheckAndMerge方法的实现,避免了遍历操作。
2026-05-07 05:17:04 +08:00
Yue-bin
1dc30bdabd perf(playermanager): 延迟初始化本地玩家信息以等待Steam准备就绪
- 添加System.Collections引用以支持协程操作
- 将LocalPlayer从立即初始化改为延迟初始化
- 实现InitAfterSteam协程来等待SteamManager初始化完成
- 在Start方法中启动初始化协程
- 在Update方法中持续更新LocalPlayer当Steam已就绪时

Co-authored-by: Copilot <copilot@github.com>
2026-05-07 05:12:35 +08:00
Yue-bin
14db933880 refactor(playermanager): 移除HostLeverHandler静态属性和日志补丁
移除了PlayerManager中的HostLeverHandler静态属性,该属性用于查找HostLeverHandler实例,
同时删除了相关的日志记录补丁LogLeverAngle,清理了不必要的调试代码。
2026-05-07 05:01:01 +08:00
Yue-bin
610cca5ef6 chore(PlayerManager): 更新插件版本号从4.0.1到4.0.2
Some checks failed
Publish Release / build (push) Failing after 5m16s
- 将stick.plugins.playermanager.csproj中的版本号从4.0.1升级到4.0.2
2026-05-07 04:54:19 +08:00
Yue-bin
d9e01bbf0f fix(PlayerManager): 修复房间公开性判断逻辑
- 移除HostLeverHandler依赖,直接通过MatchmakingHandler.LobbyType判断房间是否公开
- 修复LobbyType切换时的目标类型逻辑错误,现在正确地将公开房间设为ELobbyType.k_ELobbyTypePublic,
  私有房间设为ELobbyType.k_ELobbyTypeFriendsOnly
- 移除PlayerManagerGUI.InitializeStyles()调用,该方法已被默认组件注册替代
2026-05-07 04:53:36 +08:00
Yue-bin
31cf778524 refactor(Gui): 移除废弃样式引用并优化玩家信息按钮样式处理
移除了多个内部组件中的废弃XML文档注释和未使用的CommonButtonStyle引用,
为PlayerInfoButton添加了动态样式管理机制以替代静态样式引用。
2026-05-07 04:53:22 +08:00
Yue-bin
07d2320c71 refactor(gui): 将公开的GUI样式字段改为私有并优化样式管理
将PlayerManagerGUI中的公开静态样式字段改为私有访问修饰符,
同时为Widget组件添加默认样式属性,统一管理UI控件的外观,
提高代码封装性和样式的一致性
2026-05-07 04:53:09 +08:00
Yue-bin
ab0df1ee0d chore(playermanager): 更新版本号
All checks were successful
Publish Release / build (push) Successful in 2m3s
更新 stick.plugins.playermanager.csproj 中的版本号从 4.0.0 到 4.0.1,
以反映最新的发布版本。
2026-05-07 04:32:01 +08:00
Yue-bin
74f9e1cf1c fix(Gui): 修正LayoutCursor行进逻辑
BeginRow方法现在正确地推进到下一行,并重置列位置到OffsetX,
对齐原来的GetNewYPosition()行为。EndRow方法仅重置列位置而不行进,
下一行由BeginRow方法推进,使行进逻辑更加清晰明确。
2026-05-07 04:31:25 +08:00
Yue-bin
007c093df4 fix(Gui): 房间公开性组件不显示在同一行
- 创建List集合管理房间公开性相关的widgets
- 将房间公开性标签和按钮合并到同一个HeaderRow中
- 添加条件判断只在有相关widgets时才注册HeaderRow
- 使label和button可以在同一行独立配置显示
2026-05-07 04:31:17 +08:00
Yue-bin
fd208de2db fix(PlayerManager): 样式未被正确初始化
将PlayerManagerGUI.InitializeStyles()方法的访问修饰符从private改为internal,
并在插件启动时调用此方法来初始化GUI样式。
2026-05-07 04:30:39 +08:00
Yue-bin
40795a2f49 chore(playermanager): 升级版本到4.0.0
All checks were successful
Publish Release / build (push) Successful in 2m5s
升级PlayerManager插件版本从3.1.1到4.0.0,
以支持新的功能特性和改进。
2026-05-07 04:18:52 +08:00
Yue-bin
e157e24236 feat(gui): 添加默认组件注册器和可配置的UI组件
- 创建DefaultWidgets类作为默认组件注册入口,在Plugin.Awake()中调用RegisterAll()
- 实现多个内部组件工厂:KickButton、NameToggleButton、PlayerInfoButton、
  RecordButton、RoomPublicityButton、RoomPublicityLabel
- 将配置项访问权限从private改为internal以便外部访问
- 添加新的配置选项用于控制各个UI组件的显示开关
- 按配置条件动态注册相应的头部和玩家行组件
2026-05-07 04:16:40 +08:00
Yue-bin
dfb535e4b7 refactor(Gui): 重构PlayerManagerGUI实现纯绘制架构
- 将GUI类从包含业务逻辑改为纯绘制职责,使用LayoutCursor进行定位
- 移除硬编码的布局参数和按钮事件处理方法
- 改为迭代注册的HeaderRow/PlayerRowWidget进行绘制
- 调整GUIStyle访问修饰符为internal以供DefaultWidgets引用
2026-05-07 04:16:26 +08:00
Yue-bin
b98ae261d5 refactor(PlayerManager): 重构文件结构并更新命名空间
将GUI相关文件移动到Gui目录下,记录器相关文件移动到Recorder目录下,
并相应更新命名空间以匹配新的目录结构。

- 将PlayerManagerGUI.cs移至Gui/PlayerManagerGUI.cs
- 将BlackListRecorder.cs等记录器文件移至Recorder目录
- 更新命名空间从stick.plugins.playermanager到对应的子命名空间

Co-authored-by: Copilot <copilot@github.com>
2026-05-07 01:58:33 +08:00
Yue-bin
204fda4f59 refactor(PlayerManager): 移除未使用的lever方向控制方法并简化大厅类型切换逻辑
移除了SetHostLeverDirection方法,该方法用于修改lever朝向但不再需要。
同时重构了ChangeLobbyType方法中的大厅类型切换逻辑,使用更简洁的方式处理
公共和私有大厅类型的切换,移除了不必要的条件判断和lever旋转操作。

thank z7572

Co-authored-by: Copilot <copilot@github.com>
2026-05-07 01:51:49 +08:00
Yue-bin
023602e364 📃 docs(readme): 补充功能 2026-02-27 02:26:57 +08:00
Yue-bin
dde47af2d8 📃 docs(readme): v3.1.1 2026-02-27 02:25:43 +08:00
26 changed files with 1343 additions and 549 deletions

View File

@@ -21,6 +21,11 @@ jobs:
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore
# 直接加代理得了
env:
http_proxy: "http://172.17.0.1:7890"
https_proxy: "http://172.17.0.1:7890"
no_proxy: "localhost,127.0.0.1,*.lan"
- name: Build - name: Build
run: dotnet build --no-restore --configuration Release run: dotnet build --no-restore --configuration Release

49
Gui/DefaultWidgets.cs Normal file
View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace stick.plugins.playermanager.Gui;
/// <summary>
/// 默认组件注册入口
/// 在 Plugin.Awake() 末尾调用 RegisterAll()
/// 配置判断在此集中处理,各组件文件只提供纯 Create() 工厂
/// </summary>
public static class DefaultWidgets
{
public static void RegisterAll()
{
// Header房间公开性行label + button 在同一行,各自独立可配置)
var publicityWidgets = new List<IHeaderWidget>();
if (PlayerManager.ShowRoomPublicityLabel.Value)
publicityWidgets.Add(InternalWidgets.RoomPublicityLabel.Create());
if (PlayerManager.ShowRoomPublicityButton.Value)
publicityWidgets.Add(InternalWidgets.RoomPublicityButton.Create());
if (publicityWidgets.Count > 0)
{
RegisterManager.RegisterHeaderRow(new HeaderRow
{
Name = "RoomPublicity",
Condition = () => PlayerManager.IsHost(PlayerManager.LocalPlayer),
Children = publicityWidgets,
});
}
// 玩家行组件(始终注册)
RegisterManager.RegisterPlayerRow(InternalWidgets.PlayerInfoButton.Create());
// 玩家行组件(按配置决定)
if (PlayerManager.ShowRecordButton.Value)
RegisterManager.RegisterPlayerRow(InternalWidgets.RecordButton.Create());
if (PlayerManager.ShowKickButton.Value)
RegisterManager.RegisterPlayerRow(InternalWidgets.KickButton.Create());
if (PlayerManager.ShowNameToggleButton.Value)
RegisterManager.RegisterPlayerRow(InternalWidgets.NameToggleButton.Create());
if (PlayerManager.ShowMuteButton.Value)
RegisterManager.RegisterPlayerRow(InternalWidgets.MuteButton.Create());
if (PlayerManager.ShowAddFriendButton.Value)
RegisterManager.RegisterPlayerRow(InternalWidgets.AddFriendButton.Create());
}
}

55
Gui/GUIStyles.cs Normal file
View File

@@ -0,0 +1,55 @@
using UnityEngine;
namespace stick.plugins.playermanager.Gui;
/// <summary>
/// 集中管理所有 GUIStyle 的静态类。
/// 必须在 OnGUI 上下文中调用 Initialize() 初始化(依赖 GUI.skin
/// 所有 GUI 组件通过此类的静态字段获取样式,避免重复创建。
/// </summary>
public static class GUIStyles
{
/// <summary>标签样式fontSize=22</summary>
public static GUIStyle Label;
/// <summary>普通按钮样式fontSize=22, richText=false</summary>
public static GUIStyle Button;
/// <summary>被按过的按钮样式用于标识已经进入过的房间fontSize=22</summary>
public static GUIStyle ButtonPressed;
/// <summary>黄色按钮样式(已记录玩家)</summary>
public static GUIStyle YellowButton;
/// <summary>红色按钮样式(黑名单玩家)</summary>
public static GUIStyle RedButton;
/// <summary>绿色按钮样式(好友玩家)</summary>
public static GUIStyle GreenButton;
private static bool _initialized;
/// <summary>
/// 初始化所有样式。必须在 OnGUI 上下文中调用,允许多次调用(仅首次生效)。
/// </summary>
public static void Initialize()
{
if (_initialized) return;
Label = new GUIStyle(GUI.skin.label) { fontSize = 22 };
Button = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
ButtonPressed = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
ButtonPressed.normal.textColor = Color.grey;
YellowButton = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
YellowButton.normal.textColor = Color.yellow;
RedButton = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
RedButton.normal.textColor = Color.red;
GreenButton = new GUIStyle(GUI.skin.button) { fontSize = 22, richText = false };
GreenButton.normal.textColor = Color.green;
_initialized = true;
}
}

View File

@@ -0,0 +1,26 @@
using Steamworks;
using UnityEngine;
using stick.plugins.playermanager.Recorder;
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class AddFriendButton
{
public static IPlayerRowWidget Create()
{
return new PlayerRowButtonWidget
{
Name = "Add Friend",
GetText = player => "加好友",
Condition = player =>
!PlayerManager.IsLocalPlayer(player)
&& !player.IsFriend,
OnClick = player =>
{
GUIUtility.systemCopyBuffer = player.ToString();
SteamFriends.ActivateGameOverlayToUser("friendadd", player.SteamId);
},
Width = 100f,
};
}
}

View File

@@ -0,0 +1,21 @@
using MultiplayerBasicExample;
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class KickButton
{
public static IPlayerRowWidget Create()
{
return new PlayerRowButtonWidget
{
Name = "Kick",
Condition = player =>
!PlayerManager.IsHost(player)
&& !PlayerManager.IsLocalPlayer(player),
GetText = _ => "Kick!",
OnClick = player =>
PlayerManager.KickPlayer(player.SteamId, MultiplayerManager.KickResponse.DidNotRecievePackages),
Width = 100f,
};
}
}

View File

@@ -0,0 +1,29 @@
using MultiplayerBasicExample;
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class MuteButton
{
public static IPlayerRowWidget Create()
{
return new PlayerRowButtonWidget
{
Name = "mute",
Condition = player => !PlayerManager.IsLocalPlayer(player),
GetText = player =>
PlayerManager.IsMuted(player) ? "取消静音" : "静音",
OnClick = player =>
{
if (PlayerManager.IsMuted(player))
{
PlayerManager.UnmutePlayer(player);
}
else
{
PlayerManager.MutePlayer(player);
}
},
Width = 100f,
};
}
}

View File

@@ -0,0 +1,16 @@
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class NameToggleButton
{
public static IPlayerRowWidget Create()
{
return new PlayerRowButtonWidget
{
Name = "NameToggle",
Condition = player => player.HasRecordedName,
GetText = player => player.DisplayNameRecorded ? "显示现用名" : "显示曾用名",
OnClick = player => player.DisplayNameRecorded = !player.DisplayNameRecorded,
Width = 120f,
};
}
}

View File

@@ -0,0 +1,32 @@
using Steamworks;
using UnityEngine;
using stick.plugins.playermanager.Recorder;
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class PlayerInfoButton
{
public static IPlayerRowWidget Create()
{
return new PlayerRowButtonWidget
{
Name = "PlayerInfo",
GetText = player => player.ToString(),
OnClick = player =>
{
GUIUtility.systemCopyBuffer = player.ToString();
SteamFriends.ActivateGameOverlayToUser("steamid", player.SteamId);
},
Width = 500f,
GetStyle = player =>
{
return player.RecordStatus switch
{
PlayerRecordStatus.Blacklisted => GUIStyles.RedButton,
PlayerRecordStatus.Recorded => GUIStyles.YellowButton,
_ => player.IsFriend ? GUIStyles.GreenButton : GUIStyles.Button,
};
},
};
}
}

View File

@@ -0,0 +1,43 @@
using UnityEngine;
using stick.plugins.playermanager.Recorder;
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class RecordButton
{
public static IPlayerRowWidget Create()
{
return new PlayerRowButtonWidget
{
Name = "Record",
GetText = player => player.RecordStatus switch
{
PlayerRecordStatus.NotRecorded => "记录",
PlayerRecordStatus.Recorded => "拉黑",
PlayerRecordStatus.Blacklisted => "取消",
_ => "未知状态",
},
OnClick = player =>
{
switch (player.RecordStatus)
{
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();
},
Width = 100f,
};
}
}

View File

@@ -0,0 +1,15 @@
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class RoomPublicityButton
{
public static IHeaderWidget Create()
{
return new HeaderButtonWidget
{
Name = "RoomPublicityButton",
Text = "切换公开性",
Action = () => PlayerManager.ToggleLobbyPublicity(),
Width = 150f,
};
}
}

View File

@@ -0,0 +1,14 @@
namespace stick.plugins.playermanager.Gui.InternalWidgets;
public static class RoomPublicityLabel
{
public static IHeaderWidget Create()
{
return new HeaderLabelWidget
{
Name = "RoomPublicityLabel",
Content = () => $"房间类型: {(PlayerManager.IsPublic ? "" : "")}",
Width = 200f,
};
}
}

86
Gui/LayoutCursor.cs Normal file
View File

@@ -0,0 +1,86 @@
using UnityEngine;
namespace stick.plugins.playermanager.Gui;
/// <summary>
/// 布局游标,统一管理 GUI 绘制时的行列定位
/// Header 区域纵向排列 RowRow 内横向排列 Widget
/// 玩家区域每玩家一行,行内横向排列 Widget
/// </summary>
public class LayoutCursor
{
/// <summary>
/// 左侧偏移量
/// </summary>
public float OffsetX { get; set; } = 10f;
/// <summary>
/// 顶部偏移量
/// </summary>
public float OffsetY { get; set; } = 20f;
/// <summary>
/// 行高
/// </summary>
public float LineHeight { get; set; } = 50f;
private float _currentX;
private int _currentLine;
/// <summary>
/// 当前行的 Y 坐标
/// </summary>
public float CurrentY => OffsetY + LineHeight * _currentLine;
/// <summary>
/// 重置游标到初始状态
/// </summary>
public void Reset()
{
_currentX = OffsetX;
_currentLine = 0;
}
/// <summary>
/// 开始新的一行,推进到下一行并重置列位置到 OffsetX
/// 对齐原来的 GetNewYPosition() 行为
/// </summary>
public void BeginRow()
{
_currentLine++;
_currentX = OffsetX;
}
/// <summary>
/// 在当前行获取下一列的 RectX 坐标自动推进
/// </summary>
/// <param name="width">列宽</param>
/// <param name="height">行高</param>
public Rect NextColumn(float width, float height)
{
var rect = new Rect(_currentX, CurrentY, width, height);
_currentX += width;
return rect;
}
/// <summary>
/// 结束当前行,仅重置列位置(不行进,下一行由 BeginRow 推进)
/// </summary>
public void EndRow()
{
_currentX = OffsetX;
}
/// <summary>
/// 换行并返回一个从 OffsetX 开始的整行 Rect
/// 适用于独占整行的标题等
/// </summary>
/// <param name="width">宽度</param>
/// <param name="height">高度</param>
public Rect NextLine(float width, float height)
{
_currentLine++;
_currentX = OffsetX;
return new Rect(OffsetX, CurrentY, width, height);
}
}

215
Gui/PlayerManagerGUI.cs Normal file
View File

@@ -0,0 +1,215 @@
using System.Linq;
using MultiplayerBasicExample;
using Steamworks;
using UnityEngine;
using stick.plugins.playermanager.Recorder;
namespace stick.plugins.playermanager.Gui;
/// <summary>
/// 玩家管理器GUI类
/// 负责纯绘制:迭代注册的 HeaderRow / PlayerRowWidget使用 LayoutCursor 定位
/// 不包含任何业务逻辑或硬编码按钮
/// </summary>
public static class PlayerManagerGUI
{
#region GUI状态
/// <summary>
/// 是否显示大厅信息界面
/// </summary>
public static bool ShowLobbyInfo { get; set; }
/// <summary>
/// 滚动位置
/// </summary>
private static Vector2 ScrollPosition;
/// <summary>
/// 布局游标
/// </summary>
private static readonly LayoutCursor Cursor = new();
#endregion
#region
/// <summary>
/// 绘制大厅信息GUI
/// </summary>
public static void DrawLobbyInfoGUI()
{
GUIStyles.Initialize();
Cursor.Reset();
if (PlayerManager.MatchmakingHandler.IsInsideLobby)
{
DrawCurrentLobbyInfo();
DrawLobbySearchInfo();
}
else
{
GUI.Label(
Cursor.NextLine(500f, 40f),
"当前不在大厅中",
GUIStyles.Label
);
DrawLobbySearchInfo();
}
}
#endregion
#region
/// <summary>
/// 绘制当前大厅信息
/// 结构:标题行 → HeaderRow 区域(纵向) → 玩家行区域
/// </summary>
private static void DrawCurrentLobbyInfo()
{
// 固定:大厅标题(独占一行)
GUI.Label(
Cursor.NextLine(2000f, 40f),
$"当前大厅: {PlayerManager.CurrentLobbyID} 房主: {PlayerManager.LobbyOwner.CurrentName}",
GUIStyles.Label
);
// HeaderRow 区域:纵向迭代每一行,行内横向迭代 Children
foreach (var row in RegisterManager.HeaderRows)
{
if (!row.ShouldDraw()) continue;
Cursor.BeginRow();
foreach (var child in row.Children)
{
if (child.ShouldDraw())
child.Draw(Cursor.NextColumn(child.Width, 40f));
}
Cursor.EndRow();
}
// 玩家行区域:每玩家一行,行内横向迭代注册的 PlayerRowWidget
for (int i = 0; i < PlayerManager.lobbyMemberList.Count; i++)
{
PlayerInfo member = PlayerManager.lobbyMemberList[i];
if (!member.SteamId.IsValid()) continue;
Cursor.BeginRow();
foreach (var widget in RegisterManager.PlayerRowWidgets)
{
if (widget.ShouldDraw(member))
widget.Draw(Cursor.NextColumn(widget.Width, 40f), member);
else
Cursor.NextColumn(widget.Width, 40f); // 占位推进列位置
}
Cursor.EndRow();
}
}
#endregion
#region
/// <summary>
/// 绘制大厅搜索信息(不在大厅时显示)
/// </summary>
private static void DrawLobbySearchInfo()
{
// 搜索范围按钮
string areaText = GetAreaFilterText();
if (GUI.Button(
Cursor.NextLine(400f, 40f),
$"搜索范围 {areaText}",
GUIStyles.Button
))
{
CycleLobbyDistanceFilter();
PlayerManager.RequestLobbyList();
}
// 刷新按钮(同一行)
if (GUI.Button(
new Rect(Cursor.OffsetX + 420f, Cursor.CurrentY, 100f, 40f),
"刷新",
GUIStyles.Button
))
{
PlayerManager.RequestLobbyList();
}
// 搜索结果数量
GUI.Label(
Cursor.NextLine(500f, 40f),
$"搜索到大厅数量: {PlayerManager.lobbyDataList.Count}",
GUIStyles.Label
);
// 大厅列表滚动视图
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(Cursor.OffsetX, Cursor.CurrentY + 40f, 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];
var style = PlayerManager.enteredLobbyList.Contains(lobbyId) ? GUIStyles.ButtonPressed : GUIStyles.Button;
if (GUI.Button(
new Rect(Cursor.OffsetX, Cursor.LineHeight * i, 500f, 40f),
$"{lobbyId} 人数{SteamMatchmaking.GetNumLobbyMembers(lobbyId)}",
style
))
{
// 在大厅中则不加入
if (!PlayerManager.MatchmakingHandler.IsInsideLobby)
{
// 记录一下加入过的状态
PlayerManager.enteredLobbyList.Add(lobbyId);
PlayerManager.JoinSpecificServer(lobbyId);
}
}
}
GUI.EndScrollView();
}
#endregion
}

101
Gui/RegisterManager.cs Normal file
View File

@@ -0,0 +1,101 @@
using System.Collections.Generic;
using UnityEngine;
namespace stick.plugins.playermanager.Gui;
/// <summary>
/// 组件注册管理器
/// 提供 Header 行和玩家行组件的注册/反注册接口
/// </summary>
public static class RegisterManager
{
#region
/// <summary>
/// Header 行注册表(纵向排列)
/// </summary>
public static List<IHeaderRow> HeaderRows { get; } = new();
/// <summary>
/// 玩家行组件注册表(横向排列)
/// </summary>
public static List<IPlayerRowWidget> PlayerRowWidgets { get; } = new();
#endregion
#region
/// <summary>
/// 注册一个 Header 行,按注册顺序在 Header 区域纵向排列
/// </summary>
/// <param name="row">Header 行实例</param>
public static void RegisterHeaderRow(IHeaderRow row)
{
HeaderRows.Add(row);
Debug.Log($"[PlayerManager] 注册 Header 行: {row.Name}");
}
/// <summary>
/// 注册一个玩家行组件,按注册顺序在每个玩家行中横向排列
/// </summary>
/// <param name="widget">玩家行组件实例</param>
public static void RegisterPlayerRow(IPlayerRowWidget widget)
{
PlayerRowWidgets.Add(widget);
Debug.Log($"[PlayerManager] 注册玩家行组件: {widget.Name}");
}
#endregion
#region
/// <summary>
/// 按名称移除 Header 行
/// </summary>
/// <param name="name">行名称</param>
/// <returns>是否成功移除</returns>
public static bool UnregisterHeaderRow(string name)
{
int removed = HeaderRows.RemoveAll(r => r.Name == name);
if (removed > 0)
Debug.Log($"[PlayerManager] 移除 Header 行: {name}");
return removed > 0;
}
/// <summary>
/// 按名称移除玩家行组件
/// </summary>
/// <param name="name">组件名称</param>
/// <returns>是否成功移除</returns>
public static bool UnregisterPlayerRow(string name)
{
int removed = PlayerRowWidgets.RemoveAll(w => w.Name == name);
if (removed > 0)
Debug.Log($"[PlayerManager] 移除玩家行组件: {name}");
return removed > 0;
}
#endregion
#region
/// <summary>
/// 清空所有注册的 Header 行
/// </summary>
public static void ClearHeaderRows()
{
HeaderRows.Clear();
Debug.Log("[PlayerManager] 已清空所有 Header 行");
}
/// <summary>
/// 清空所有注册的玩家行组件
/// </summary>
public static void ClearPlayerRowWidgets()
{
PlayerRowWidgets.Clear();
Debug.Log("[PlayerManager] 已清空所有玩家行组件");
}
#endregion
}

207
Gui/Widget.cs Normal file
View File

@@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using stick.plugins.playermanager.Recorder;
using UnityEngine;
namespace stick.plugins.playermanager.Gui;
#region Header
/// <summary>
/// 单个 Header 组件接口Label 或 Button
/// 不直接注册到 RegisterManager而是放在 IHeaderRow.Children 中
/// </summary>
public interface IHeaderWidget
{
/// <summary>
/// 组件名称,用于日志和反注册
/// </summary>
string Name { get; }
/// <summary>
/// 是否应该绘制此组件
/// </summary>
bool ShouldDraw();
/// <summary>
/// 组件宽度
/// </summary>
float Width { get; }
/// <summary>
/// 在指定 Rect 内绘制组件
/// </summary>
void Draw(Rect rect);
}
/// <summary>
/// Header 行接口
/// 一个 HeaderRow 占 Header 区域的一整行,其 Children 在行内横向排列
/// </summary>
public interface IHeaderRow
{
/// <summary>
/// 行名称,用于日志和反注册
/// </summary>
string Name { get; }
/// <summary>
/// 是否应该绘制此行
/// </summary>
bool ShouldDraw();
/// <summary>
/// 行内的子组件列表Label / Button按顺序横向排列
/// </summary>
IList<IHeaderWidget> Children { get; }
}
#endregion
#region
/// <summary>
/// 玩家行组件接口Label 或 Button
/// 注册到 RegisterManager 后,在每个玩家行中横向排列
/// </summary>
public interface IPlayerRowWidget
{
/// <summary>
/// 组件名称,用于日志和反注册
/// </summary>
string Name { get; }
/// <summary>
/// 判断是否应该为指定玩家绘制此组件
/// </summary>
bool ShouldDraw(PlayerInfo player);
/// <summary>
/// 组件宽度
/// </summary>
float Width { get; }
/// <summary>
/// 在指定 Rect 内为指定玩家绘制组件
/// </summary>
void Draw(Rect rect, PlayerInfo player);
}
#endregion
#region Header
/// <summary>
/// Header 标签组件
/// </summary>
public class HeaderLabelWidget : IHeaderWidget
{
public string Name { get; set; }
public Func<bool> Condition { get; set; }
public Func<string> Content { get; set; }
public float Width { get; set; }
public GUIStyle Style { get; set; }
public bool ShouldDraw() => Condition == null || Condition();
public void Draw(Rect rect)
{
GUI.Label(rect, Content?.Invoke() ?? string.Empty, Style ?? GUIStyles.Label);
}
}
/// <summary>
/// Header 按钮组件
/// </summary>
public class HeaderButtonWidget : IHeaderWidget
{
public string Name { get; set; }
public Func<bool> Condition { get; set; }
public string Text { get; set; }
public Action Action { get; set; }
public float Width { get; set; }
public GUIStyle Style { get; set; }
public bool ShouldDraw() => Condition == null || Condition();
public void Draw(Rect rect)
{
if (GUI.Button(rect, Text ?? string.Empty, Style ?? GUIStyles.Button))
Action?.Invoke();
}
}
/// <summary>
/// Header 行具体实现
/// 包含一组 IHeaderWidget在 Header 区域占一整行
/// </summary>
public class HeaderRow : IHeaderRow
{
public string Name { get; set; }
public Func<bool> Condition { get; set; }
/// <summary>
/// 行内的子组件列表,按添加顺序横向排列
/// </summary>
public List<IHeaderWidget> Children { get; set; } = new();
IList<IHeaderWidget> IHeaderRow.Children => Children;
public bool ShouldDraw() => Condition == null || Condition();
}
#endregion
#region
/// <summary>
/// 玩家行按钮组件
/// </summary>
public class PlayerRowButtonWidget : IPlayerRowWidget
{
public string Name { get; set; }
public Func<PlayerInfo, bool> Condition { get; set; }
public Func<PlayerInfo, string> GetText { get; set; }
public Action<PlayerInfo> OnClick { get; set; }
public float Width { get; set; }
/// <summary>
/// 静态样式(当 GetStyle 为 null 或返回 null 时使用)
/// </summary>
public GUIStyle Style { get; set; }
/// <summary>
/// 动态样式获取(可选)。优先于 Style用于根据玩家状态切换按钮颜色
/// </summary>
public Func<PlayerInfo, GUIStyle> GetStyle { get; set; }
public bool ShouldDraw(PlayerInfo player) => Condition == null || Condition(player);
public void Draw(Rect rect, PlayerInfo player)
{
GUIStyle style = GetStyle?.Invoke(player) ?? Style ?? GUIStyles.Button;
if (GUI.Button(rect, GetText?.Invoke(player) ?? string.Empty, style))
OnClick?.Invoke(player);
}
}
/// <summary>
/// 玩家行标签组件
/// </summary>
public class PlayerRowLabelWidget : IPlayerRowWidget
{
public string Name { get; set; }
public Func<PlayerInfo, bool> Condition { get; set; }
public Func<PlayerInfo, string> GetText { get; set; }
public float Width { get; set; }
public GUIStyle Style { get; set; }
public bool ShouldDraw(PlayerInfo player) => Condition == null || Condition(player);
public void Draw(Rect rect, PlayerInfo player)
{
GUI.Label(rect, GetText?.Invoke(player) ?? string.Empty, Style ?? GUIStyles.Label);
}
}
#endregion

View File

@@ -0,0 +1,22 @@
using System.Reflection.Emit;
using System.Collections.Generic;
using HarmonyLib;
using stick.plugins.playermanager;
[HarmonyPatch(typeof(MatchmakingHandler), "OnRandomServerJoinFailed")]
class DistanceFilterPatch
{
public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
return new CodeMatcher(instructions)
.MatchForward(false,
new CodeMatch(OpCodes.Ldc_I4_1),
new CodeMatch(OpCodes.Stloc_0)
)
.SetInstruction(
new CodeInstruction(OpCodes.Ldsfld,
AccessTools.Field(typeof(PlayerManager), nameof(PlayerManager.lobbyDistanceFilter))))
.InstructionEnumeration();
}
}

15
Patches/MutePatch.cs Normal file
View File

@@ -0,0 +1,15 @@
using HarmonyLib;
using stick.plugins.playermanager;
[HarmonyPatch(typeof(NetworkPlayer), "SyncClientChat")]
class MutePatch
{
public static bool Prefix(NetworkPlayer __instance)
{
if (PlayerManager.MutedPlayers.Contains(__instance.NetworkSpawnID))
{
return false;
}
return true;
}
}

View File

@@ -1,470 +0,0 @@
using System.Linq;
using MultiplayerBasicExample;
using Steamworks;
using UnityEngine;
namespace stick.plugins.playermanager;
/// <summary>
/// 玩家管理器GUI类
/// 负责绘制大厅信息界面
/// </summary>
public static class PlayerManagerGUI
{
#region GUI状态
/// <summary>
/// 是否显示大厅信息界面
/// </summary>
public static bool ShowLobbyInfo { get; set; }
/// <summary>
/// 滚动位置
/// </summary>
private static Vector2 ScrollPosition;
/// <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;
OffsetY = 20f;
LineHeight = 50f;
LineIndex = 0f;
}
/// <summary>
/// 初始化GUIStyle
/// </summary>
private static void InitializeStyles()
{
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;
}
#endregion
#region
/// <summary>
/// 获取新行的Y坐标
/// </summary>
private static float GetNewYPosition()
{
return OffsetY + LineHeight * ++LineIndex;
}
/// <summary>
/// 获取当前行的Y坐标
/// </summary>
private static float GetCurrentYPosition()
{
return OffsetY + LineHeight * LineIndex;
}
#endregion
#region
/// <summary>
/// 根据玩家状态获取按钮样式
/// </summary>
private static GUIStyle GetButtonStyle(PlayerInfo player)
{
return player.RecordStatus switch
{
PlayerRecordStatus.Blacklisted => RedButtonStyle,
PlayerRecordStatus.Recorded => YellowButtonStyle,
_ => player.IsFriend ? GreenButtonStyle : CommonButtonStyle
};
}
/// <summary>
/// 获取记录按钮文本
/// </summary>
private static string GetRecordButtonText(PlayerInfo player)
{
return player.RecordStatus switch
{
PlayerRecordStatus.NotRecorded => "记录",
PlayerRecordStatus.Recorded => "拉黑",
PlayerRecordStatus.Blacklisted => "取消",
_ => "未知状态"
};
}
/// <summary>
/// 获取曾用名切换按钮文本
/// </summary>
private static string GetNameToggleButtonText(PlayerInfo player)
{
return player.DisplayNameRecorded ? "显示现用名" : "显示曾用名";
}
/// <summary>
/// 获取房间公开性文本
/// </summary>
private static string GetRoomPublicText()
{
return PlayerManager.IsPublic ? "公开" : "好友可见";
}
#endregion
#region
/// <summary>
/// 处理房间公开切换按钮点击
/// </summary>
private static void OnRoomPublicToggleButtonClick()
{
PlayerManager.ToggleLobbyPublicity();
}
/// <summary>
/// 处理记录按钮点击
/// </summary>
private static void OnRecordButtonClick(PlayerInfo player)
{
switch (player.RecordStatus)
{
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();
PlayerManager.RequestLobbyList();
}
/// <summary>
/// 处理刷新大厅列表按钮点击
/// </summary>
private static void OnRefreshLobbyListClick()
{
PlayerManager.RequestLobbyList();
}
#endregion
#region GUI绘制方法
/// <summary>
/// 绘制当前大厅信息
/// </summary>
private static void DrawCurrentLobbyInfo()
{
InitializeStyles();
// 绘制大厅标题
GUI.Label(
new Rect(OffsetX, GetNewYPosition(), 2000f, 40f),
$"当前大厅: {PlayerManager.CurrentLobbyID} 房主: {PlayerManager.LobbyOwner.CurrentName}",
TitleStyle
);
if (PlayerManager.IsHost(PlayerManager.LocalPlayer))
{
// 绘制房间公开性
GUI.Label(
new Rect(OffsetX, GetNewYPosition(), 200f, 40f),
$"房间类型: {GetRoomPublicText()}",
TitleStyle
);
// 绘制房间公开切换按钮
if (GUI.Button(
new Rect(OffsetX + 220f, GetCurrentYPosition(), 150f, 40f),
"切换公开性",
CommonButtonStyle
))
{
OnRoomPublicToggleButtonClick();
}
}
// 绘制大厅成员列表
for (int i = 0; i < PlayerManager.lobbyMemberList.Count; i++)
{
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
))
{
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();
}
#endregion
#region
/// <summary>
/// 绘制大厅信息GUI
/// </summary>
public static void DrawLobbyInfoGUI()
{
InitializeLayout();
InitializeStyles();
if (PlayerManager.MatchmakingHandler.IsInsideLobby)
{
DrawCurrentLobbyInfo();
}
else
{
GUI.Label(
new Rect(OffsetX, GetNewYPosition(), 500f, 40f),
"当前不在大厅中",
TitleStyle
);
DrawLobbySearchInfo();
}
}
#endregion
}

241
Plugin.cs
View File

@@ -7,6 +7,10 @@ using Steamworks;
using HarmonyLib; using HarmonyLib;
using System.Data; using System.Data;
using System.Reflection; using System.Reflection;
using stick.plugins.playermanager.Recorder;
using stick.plugins.playermanager.Gui;
using System.Collections;
using MultiplayerBasicExample;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager;
@@ -23,12 +27,47 @@ public class PlayerManager : BaseUnityPlugin
/// <summary> /// <summary>
/// 是否屏蔽来自其他玩家的踢出请求 /// 是否屏蔽来自其他玩家的踢出请求
/// </summary> /// </summary>
private static ConfigEntry<bool> _blockKicks; internal static ConfigEntry<bool> _blockKicks;
/// <summary> /// <summary>
/// 大厅信息开关快捷键 /// 大厅信息开关快捷键
/// </summary> /// </summary>
private static ConfigEntry<KeyCode> _lobbyInfoToggleKeyCode; internal static ConfigEntry<KeyCode> _lobbyInfoToggleKeyCode;
/// <summary>
/// 是否显示踢人按钮
/// </summary>
internal static ConfigEntry<bool> ShowKickButton;
/// <summary>
/// 是否显示记录/拉黑按钮
/// </summary>
internal static ConfigEntry<bool> ShowRecordButton;
/// <summary>
/// 是否显示曾用名切换按钮
/// </summary>
internal static ConfigEntry<bool> ShowNameToggleButton;
/// <summary>
/// 是否显示房间类型标签
/// </summary>
internal static ConfigEntry<bool> ShowRoomPublicityLabel;
/// <summary>
/// 是否显示切换公开性按钮
/// </summary>
internal static ConfigEntry<bool> ShowRoomPublicityButton;
/// <summary>
/// 是否显示禁音按钮
/// </summary>
internal static ConfigEntry<bool> ShowMuteButton;
/// <summary>
/// 是否显示加好友按钮
/// </summary>
internal static ConfigEntry<bool> ShowAddFriendButton;
#endregion #endregion
@@ -63,6 +102,11 @@ public class PlayerManager : BaseUnityPlugin
/// </summary> /// </summary>
public static List<CSteamID> lobbyDataList = []; public static List<CSteamID> lobbyDataList = [];
/// <summary>
/// 进入过的大厅列表
/// </summary>
public static HashSet<CSteamID> enteredLobbyList = [];
/// <summary> /// <summary>
/// 大厅距离过滤设置 /// 大厅距离过滤设置
/// </summary> /// </summary>
@@ -77,6 +121,11 @@ public class PlayerManager : BaseUnityPlugin
#region 访 #region 访
/// <summary>
/// 多人管理器实例
/// </summary>
public static MultiplayerManager MultiplayerManager => FindObjectOfType<MultiplayerManager>();
/// <summary> /// <summary>
/// 大厅管理器实例 /// 大厅管理器实例
/// </summary> /// </summary>
@@ -90,15 +139,10 @@ public class PlayerManager : BaseUnityPlugin
"ChangeLobbyType" "ChangeLobbyType"
); );
/// <summary>
/// leverhandler实例
/// </summary>
public static HostLeverHandler HostLeverHandler => UnityEngine.Object.FindObjectOfType<HostLeverHandler>();
/// <summary> /// <summary>
/// 房间公开性 /// 房间公开性
/// </summary> /// </summary>
public static bool IsPublic => HostLeverHandler?.isOn ?? MatchmakingHandler.LobbyType == ELobbyType.k_ELobbyTypePublic; public static bool IsPublic => MatchmakingHandler.LobbyType == ELobbyType.k_ELobbyTypePublic;
/// <summary> /// <summary>
/// 当前大厅ID /// 当前大厅ID
@@ -113,7 +157,29 @@ public class PlayerManager : BaseUnityPlugin
/// <summary> /// <summary>
/// 本地玩家信息 /// 本地玩家信息
/// </summary> /// </summary>
public static PlayerInfo LocalPlayer => new(SteamUser.GetSteamID()); public static PlayerInfo LocalPlayer;
/// <summary>
/// 初始化localPlayer和只需要初始化一次的steam相关的协程
/// </summary>
IEnumerator InitAfterSteam()
{
// 等待 Steam 初始化(给游戏一点初始化时间)
yield return new WaitForSeconds(2f);
// 或者轮询检查 Steam 是否就绪
while (!SteamManager.Initialized) // 如果游戏有 SteamManager
{
yield return null;
}
LocalPlayer = new(SteamUser.GetSteamID());
}
/// <summary>
/// 禁音玩家列表
/// </summary>
public static HashSet<ushort> MutedPlayers = [];
#endregion #endregion
@@ -123,11 +189,21 @@ public class PlayerManager : BaseUnityPlugin
{ {
InitializeConfiguration(); InitializeConfiguration();
ApplyPatches(); ApplyPatches();
DefaultWidgets.RegisterAll();
}
void Start()
{
StartCoroutine(InitAfterSteam());
} }
private void Update() private void Update()
{ {
HandleInput(); HandleInput();
if (SteamManager.Initialized && LocalPlayer is null)
{
LocalPlayer = new(SteamUser.GetSteamID());
}
} }
private void OnGUI() private void OnGUI()
@@ -160,6 +236,55 @@ public class PlayerManager : BaseUnityPlugin
KeyCode.F2, KeyCode.F2,
"Lobby信息开关快捷键" "Lobby信息开关快捷键"
); );
ShowKickButton = Config.Bind(
"GUI",
"ShowKickButton",
true,
"是否显示踢人按钮"
);
ShowRecordButton = Config.Bind(
"GUI",
"ShowRecordButton",
true,
"是否显示记录/拉黑按钮"
);
ShowNameToggleButton = Config.Bind(
"GUI",
"ShowNameToggleButton",
true,
"是否显示曾用名切换按钮"
);
ShowMuteButton = Config.Bind(
"GUI",
"ShowMuteButton",
true,
"是否显示禁音按钮"
);
ShowRoomPublicityLabel = Config.Bind(
"GUI",
"ShowRoomPublicityLabel",
true,
"是否显示房间类型标签"
);
ShowRoomPublicityButton = Config.Bind(
"GUI",
"ShowRoomPublicityButton",
true,
"是否显示切换公开性按钮"
);
ShowAddFriendButton = Config.Bind(
"GUI",
"ShowAddFriendButton",
true,
"是否显示加好友按钮"
);
} }
/// <summary> /// <summary>
@@ -173,6 +298,8 @@ public class PlayerManager : BaseUnityPlugin
} }
Harmony.CreateAndPatchAll(typeof(ApplyBlacklist)); Harmony.CreateAndPatchAll(typeof(ApplyBlacklist));
Harmony.CreateAndPatchAll(typeof(ListenForChange)); Harmony.CreateAndPatchAll(typeof(ListenForChange));
Harmony.CreateAndPatchAll(typeof(MutePatch));
Harmony.CreateAndPatchAll(typeof(DistanceFilterPatch));
} }
/// <summary> /// <summary>
@@ -203,30 +330,64 @@ public class PlayerManager : BaseUnityPlugin
Debug.Log("大厅信息界面已打开"); Debug.Log("大厅信息界面已打开");
} }
} }
/// <summary>
/// 修改lever朝向
/// </summary>
/// <param name="angle">朝向角度</param>
/// <returns>修改成功返回true</returns>
private static bool SetHostLeverDirection(float angle)
{
if (HostLeverHandler is not null)
{
var rig = HostLeverHandler.lever.GetComponent<Rigidbody>();
if (rig is not null)
{
rig.MoveRotation(Quaternion.Euler(0, angle, 0));
return true;
}
}
return false;
}
#endregion #endregion
#region #region
/// <summary>
/// 通过SteamID获取NetworkSpawnID
/// </summary>
/// <param name="targetSteamID"></param>
/// <returns></returns>
public static ushort GetNetworkSpawnIDBySteamID(CSteamID targetSteamID)
{
ConnectedClientData[] clients = MultiplayerManager.ConnectedClients;
for (ushort i = 0; i < clients.Length; i++)
{
if (clients[i] != null && clients[i].ClientID == targetSteamID)
{
return i; // i 就是 NetworkSpawnID
}
}
return 65535; // ushort.MaxValue表示未找到与默认值一致
}
/// <summary>
/// 判断指定玩家是否被静音
/// </summary>
/// <param name="player">要检查的玩家</param>
/// <returns>如果被静音返回true</returns>
public static bool IsMuted(PlayerInfo player)
{
return player != null && MutedPlayers.Contains(player.NetworkSpawnID);
}
/// <summary>
/// 静音指定玩家
/// </summary>
/// <param name="player">被静音的玩家</param>
public static void MutePlayer(PlayerInfo player)
{
if (player != null)
{
MutedPlayers.Add(player.NetworkSpawnID);
Debug.Log($"玩家 {player} 已被静音");
}
}
/// <summary>
/// 取消静音指定玩家
/// </summary>
/// <param name="player">要取消静音的玩家</param>
public static void UnmutePlayer(PlayerInfo player)
{
if (player != null && MutedPlayers.Contains(player.NetworkSpawnID))
{
MutedPlayers.Remove(player.NetworkSpawnID);
Debug.Log($"玩家 {player} 已被取消静音");
}
}
/// <summary> /// <summary>
/// 判断指定玩家是否为房主 /// 判断指定玩家是否为房主
/// </summary> /// </summary>
@@ -254,24 +415,9 @@ public class PlayerManager : BaseUnityPlugin
{ {
if (MultiplayerManager.IsServer) if (MultiplayerManager.IsServer)
{ {
if (IsPublic) var targetType = IsPublic ? ELobbyType.k_ELobbyTypeFriendsOnly : ELobbyType.k_ELobbyTypePublic;
{ MatchmakingHandler.SetNewLobbyType(targetType);
MatchmakingHandler.SetNewLobbyType(ELobbyType.k_ELobbyTypeFriendsOnly); RawChangeLobbyTypeMI.Invoke(MatchmakingHandler, [targetType]);
if (!SetHostLeverDirection(30f))
{
// 如果lever不存在则直接调用原始方法
RawChangeLobbyTypeMI.Invoke(MatchmakingHandler, [ELobbyType.k_ELobbyTypeFriendsOnly]);
}
}
else
{
MatchmakingHandler.SetNewLobbyType(ELobbyType.k_ELobbyTypePublic);
if (!SetHostLeverDirection(-30f))
{
// 如果lever不存在则直接调用原始方法
RawChangeLobbyTypeMI.Invoke(MatchmakingHandler, [ELobbyType.k_ELobbyTypePublic]);
}
}
} }
} }
@@ -293,6 +439,7 @@ public class PlayerManager : BaseUnityPlugin
{ {
Debug.Log("大厅列表请求完成: " + result.m_nLobbiesMatching + " 个大厅"); Debug.Log("大厅列表请求完成: " + result.m_nLobbiesMatching + " 个大厅");
lobbyDataList = []; lobbyDataList = [];
enteredLobbyList = [];
uint lobbyCount = result.m_nLobbiesMatching; uint lobbyCount = result.m_nLobbiesMatching;
for (uint i = 0; i < lobbyCount; i++) for (uint i = 0; i < lobbyCount; i++)

View File

@@ -1,4 +1,4 @@
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager.Recorder;
/// <summary> /// <summary>
/// 黑名单记录器,用于记录需要屏蔽的玩家 /// 黑名单记录器,用于记录需要屏蔽的玩家

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Steamworks; using Steamworks;
using UnityEngine; using UnityEngine;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager.Recorder;
/// <summary> /// <summary>
/// 玩家信息类 /// 玩家信息类
@@ -43,6 +43,12 @@ public class PlayerInfo : IEquatable<PlayerInfo>
/// </summary> /// </summary>
public PlayerRecordStatus RecordStatus { get; set; } public PlayerRecordStatus RecordStatus { get; set; }
/// <summary>
/// 柴自己的NetworkSpawnID
/// </summary>
public ushort NetworkSpawnID => PlayerManager.GetNetworkSpawnIDBySteamID(SteamId);
/// <summary> /// <summary>
/// 当前玩家名称(实时获取) /// 当前玩家名称(实时获取)
/// </summary> /// </summary>

View File

@@ -1,4 +1,4 @@
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager.Recorder;
/// <summary> /// <summary>
/// 玩家记录器,用于记录普通玩家信息 /// 玩家记录器,用于记录普通玩家信息

View File

@@ -4,25 +4,21 @@ using MultiplayerBasicExample;
using Steamworks; using Steamworks;
using UnityEngine; using UnityEngine;
namespace stick.plugins.playermanager; namespace stick.plugins.playermanager.Recorder;
/// <summary> /// <summary>
/// 记录器基类 /// 记录器基类
/// 提供玩家记录和黑名单功能的通用实现 /// 提供玩家记录和黑名单功能的通用实现
/// 使用 Dictionary<CSteamID, PlayerInfo> 实现 O(1) 查找
/// </summary> /// </summary>
public abstract class RecorderBase public abstract class RecorderBase
{ {
#region #region
/// <summary> /// <summary>
/// 玩家信息集合(用于快速查找) /// 玩家信息字典(以 SteamID 为键O(1) 查找)
/// </summary> /// </summary>
protected HashSet<PlayerInfo> playerSet = new HashSet<PlayerInfo>(); protected Dictionary<CSteamID, PlayerInfo> playerDict = new Dictionary<CSteamID, PlayerInfo>();
/// <summary>
/// 玩家信息列表(用于有序遍历和文件写入)
/// </summary>
protected List<PlayerInfo> playerList = new List<PlayerInfo>();
#endregion #endregion
@@ -68,7 +64,7 @@ public abstract class RecorderBase
/// </summary> /// </summary>
public bool IsPlayerRecorded(PlayerInfo playerInfo) public bool IsPlayerRecorded(PlayerInfo playerInfo)
{ {
return playerInfo != null && playerSet.Contains(playerInfo); return playerInfo != null && playerDict.ContainsKey(playerInfo.SteamId);
} }
/// <summary> /// <summary>
@@ -76,8 +72,7 @@ public abstract class RecorderBase
/// </summary> /// </summary>
public bool IsPlayerRecorded(CSteamID playerId) public bool IsPlayerRecorded(CSteamID playerId)
{ {
PlayerInfo playerInfo = new PlayerInfo(playerId); return playerDict.ContainsKey(playerId);
return playerSet.Contains(playerInfo);
} }
/// <summary> /// <summary>
@@ -89,9 +84,9 @@ public abstract class RecorderBase
playerInfo.RecordStatus = RecordStatus; playerInfo.RecordStatus = RecordStatus;
if (playerSet.Add(playerInfo)) if (!playerDict.ContainsKey(playerInfo.SteamId))
{ {
playerList.Add(playerInfo); playerDict.Add(playerInfo.SteamId, playerInfo);
} }
AppendToFile(playerInfo); AppendToFile(playerInfo);
@@ -104,27 +99,22 @@ public abstract class RecorderBase
{ {
if (playerInfo == null) return; if (playerInfo == null) return;
if (playerSet.Remove(playerInfo)) if (playerDict.Remove(playerInfo.SteamId))
{ {
playerList.Remove(playerInfo);
WriteBackToFile(); WriteBackToFile();
} }
} }
/// <summary> /// <summary>
/// 检查并合并玩家记录信息 /// 检查并合并玩家记录信息O(1) 字典查找)
/// </summary> /// </summary>
public void CheckAndMerge(PlayerInfo playerInfo) public void CheckAndMerge(PlayerInfo playerInfo)
{ {
if (playerInfo == null) return; if (playerInfo == null) return;
foreach (PlayerInfo existingInfo in playerSet) if (playerDict.TryGetValue(playerInfo.SteamId, out PlayerInfo existingInfo))
{ {
if (existingInfo.Equals(playerInfo)) playerInfo.MergeWith(existingInfo);
{
playerInfo.MergeWith(existingInfo);
return;
}
} }
} }
@@ -142,8 +132,7 @@ public abstract class RecorderBase
return; return;
} }
playerSet.Clear(); playerDict.Clear();
playerList.Clear();
using (StreamReader reader = new StreamReader(FilePath)) using (StreamReader reader = new StreamReader(FilePath))
{ {
@@ -157,9 +146,9 @@ public abstract class RecorderBase
RecordStatus = RecordStatus RecordStatus = RecordStatus
}; };
if (playerSet.Add(playerInfo)) if (!playerDict.ContainsKey(playerInfo.SteamId))
{ {
playerList.Add(playerInfo); playerDict.Add(playerInfo.SteamId, playerInfo);
} }
} }
} }
@@ -172,7 +161,7 @@ public abstract class RecorderBase
{ {
using (StreamWriter writer = new StreamWriter(FilePath, false)) using (StreamWriter writer = new StreamWriter(FilePath, false))
{ {
foreach (PlayerInfo info in playerList) foreach (PlayerInfo info in playerDict.Values)
{ {
writer.WriteLine(info.ToInfoString()); writer.WriteLine(info.ToInfoString());
} }

173
readme.md
View File

@@ -6,7 +6,6 @@
- 房间查找 - 房间查找
- 玩家记录 - 玩家记录
- 被记录的玩家显示为黄名 - 被记录的玩家显示为黄名
- 是steam好友的玩家显示为绿名优先级更高 - 是steam好友的玩家显示为绿名优先级更高
- 被拉黑的玩家显示为红名(优先级最高) - 被拉黑的玩家显示为红名(优先级最高)
@@ -14,9 +13,41 @@
- 拉黑玩家,当你是房主的时候生效 - 拉黑玩家,当你是房主的时候生效
- 可选择开启,默认开启的防踢 - 可选择开启,默认开启的防踢
- 可配置的界面开启按键默认为F2 - 可配置的界面开启按键默认为F2
- 可以随时切换房间的公开性
- 静音单个玩家
- 一键加好友
## 更新日志 ## 更新日志
### v4.0.6
1. 大厅搜索范围按钮现在可同步修改快速匹配的搜索范围
2. 修复了禁音功能 *难道他真的是猪??*
### v4.0.5
1. 修复了加好友按钮始终不显示的问题 *难道他真的是猪??*
### v4.0.4
1. 房间列表现在在房间内也显示,但是点击无效
2. 添加了加好友按钮
3. 修复了禁音功能 *难道他真的是猪??*
### v4.0.3
1. 静音单个玩家
2. 性能优化
3. 提供了更多可选是否开启功能显示的配置项
4. 提供按钮组件注册入口
5. 更好的房间公开性切换
### v3.1.1
1. 添加了房间公开性切换功能
2. 更改房间筛选条件现在会触发刷新列表
3. 现在房间列表不会在房间内显示,防止误触导致的游戏状态不一致
4. 一些重构和优化
### v3.0.3 ### v3.0.3
1. 修复了kick按钮无论如何都会显示的问题难道他真的是猪 1. 修复了kick按钮无论如何都会显示的问题难道他真的是猪
@@ -64,3 +95,143 @@
感谢老狼的原版插件 感谢老狼的原版插件
旧版插件用户可以通过把 `...\StickFightTheGame\StickFight_Data`下的 `CustomPlayerRecord.txt`文件移动到 `...\StickFightTheGame\BepInEx\config\`目录来保留以前的记录 旧版插件用户可以通过把 `...\StickFightTheGame\StickFight_Data`下的 `CustomPlayerRecord.txt`文件移动到 `...\StickFightTheGame\BepInEx\config\`目录来保留以前的记录
## 接口文档
### 组件注册
v4.0.3 起开放 GUI 组件注册,允许其他插件向界面的 Header 区或玩家行插入自定义控件。
命名空间:`stick.plugins.playermanager.Gui`
---
#### `RegisterManager` — 注册/反注册 API
```csharp
// 注册一个 Header 行(纵向排列,行内子组件横向排列)
static void RegisterHeaderRow(IHeaderRow row);
// 注册一个玩家行组件(每个玩家行中横向排列)
static void RegisterPlayerRow(IPlayerRowWidget widget);
// 按 Name 反注册
static bool UnregisterHeaderRow(string name);
static bool UnregisterPlayerRow(string name);
```
---
#### 玩家行组件
每个大厅成员对应一行,注册的组件在行内从左到右排列。两种组件:
**`PlayerRowButtonWidget`** — 可点击按钮
| 参数 | 类型 | 必填 | 说明 |
| ----------- | ---------------------------- | ---- | ------------------------------- |
| `Name` | `string` | ✓ | 唯一标识,用于反注册 |
| `Width` | `float` | ✓ | 组件宽度(像素) |
| `GetText` | `Func<PlayerInfo, string>` | ✓ | 按钮文字,`player` 为当前行玩家 |
| `OnClick` | `Action<PlayerInfo>` | ✓ | 点击回调 |
| `Condition` | `Func<PlayerInfo, bool>` | | 返回 `false` 时对该玩家隐藏 |
| `Style` | `GUIStyle` | | 静态样式 |
| `GetStyle` | `Func<PlayerInfo, GUIStyle>` | | 动态样式,优先于 `Style` |
**`PlayerRowLabelWidget`** — 只读文本
| 参数 | 类型 | 必填 | 说明 |
| ----------- | -------------------------- | ---- | ------------------- |
| `Name` | `string` | ✓ | 唯一标识 |
| `Width` | `float` | ✓ | 组件宽度(像素) |
| `GetText` | `Func<PlayerInfo, string>` | ✓ | 显示文本 |
| `Condition` | `Func<PlayerInfo, bool>` | | 返回 `false` 时隐藏 |
| `Style` | `GUIStyle` | | 自定义样式 |
---
#### Header 组件
Header 区位于标题下方,每行可含多个子组件。两种子组件:
**`HeaderRow`** — 一个 Header 行
| 参数 | 类型 | 必填 | 说明 |
| ----------- | --------------------- | ---- | ----------------------- |
| `Name` | `string` | ✓ | 唯一标识 |
| `Children` | `List<IHeaderWidget>` | ✓ | 行内子组件列表 |
| `Condition` | `Func<bool>` | | 返回 `false` 时整行隐藏 |
**`HeaderLabelWidget`** — 行内文本
| 参数 | 类型 | 必填 | 说明 |
| ----------- | -------------- | ---- | ------------------- |
| `Name` | `string` | ✓ | 唯一标识 |
| `Width` | `float` | ✓ | 组件宽度(像素) |
| `Content` | `Func<string>` | ✓ | 显示文本 |
| `Condition` | `Func<bool>` | | 返回 `false` 时隐藏 |
| `Style` | `GUIStyle` | | 自定义样式 |
**`HeaderButtonWidget`** — 行内按钮
| 参数 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ------------------- |
| `Name` | `string` | ✓ | 唯一标识 |
| `Width` | `float` | ✓ | 组件宽度(像素) |
| `Text` | `string` | ✓ | 按钮文字 |
| `Action` | `Action` | ✓ | 点击回调 |
| `Condition` | `Func<bool>` | | 返回 `false` 时隐藏 |
| `Style` | `GUIStyle` | | 自定义样式 |
---
#### 示例
```csharp
using stick.plugins.playermanager.Gui;
using stick.plugins.playermanager.Recorder;
// 玩家行按钮
RegisterManager.RegisterPlayerRow(new PlayerRowButtonWidget
{
Name = "MyButton",
GetText = player => "打招呼",
OnClick = player => Debug.Log($"你好 {player}"),
Width = 100f,
Condition = player => !PlayerManager.IsLocalPlayer(player),
});
// 玩家行标签
RegisterManager.RegisterPlayerRow(new PlayerRowLabelWidget
{
Name = "MyLabel",
GetText = player => $"#{player.SteamId}",
Width = 150f,
Style = GUIStyles.Label,
});
// Header 行(含标签 + 按钮)
RegisterManager.RegisterHeaderRow(new HeaderRow
{
Name = "MyHeader",
Condition = () => PlayerManager.IsHost(PlayerManager.LocalPlayer),
Children = new List<IHeaderWidget>
{
new HeaderLabelWidget
{
Name = "Status",
Content = () => "状态: 运行中",
Width = 150f,
},
new HeaderButtonWidget
{
Name = "Action",
Text = "执行",
Action = () => Debug.Log("点击"),
Width = 100f,
},
},
});
// 反注册
RegisterManager.UnregisterPlayerRow("MyButton");

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.1.1</Version> <Version>4.0.6</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<RestoreAdditionalProjectSources> <RestoreAdditionalProjectSources>