简介:这份资源面向希望在虚幻引擎4中接入Steam好友系统的C++开发者,围绕Steam Friends API的集成方式给出可运行的演示工程。内容包含一个完整的蓝图节点类,用于执行获取好友列表等网络等待任务,从Steam子系统中拉取FriendsList;同时提供一个标准蓝图函数库,封装邀请好友的功能;另有自定义游戏实例类,负责在邀请被接受后加入对应会话。资源共7个文件,由3个h头文件、3个cpp源文件和1个md说明文档组成,压缩包约7KB,体量轻巧,便于直接嵌入现有UE4项目参考。目前已有311人学习,适合具备一定C++与蓝图基础、正在做Steam联机功能的中高级开发者,可借此理清好友列表获取、邀请发送与会话加入的调用链路,快速搭建可调试的联机原型。
1. 从一次联机调试翻车说起:这个 UE4 项目到底在干什么
如果你在 UE4 里接过 Steam 的联机功能,大概率经历过这种场面:游戏在编辑器里跑得好好的,一打包成独立进程,好友列表刷不出来,邀请点了没反应,Overlay 面板死活不弹。翻日志发现 SteamAPI 初始化失败,但代码明明照着文档写的。问题往往出在初始化时机、AppID 配置和回调绑定这三件事上,而这三件事恰好是文档里最容易一笔带过的地方。
这个项目就是一个专门用来演示「UE4 集成 Steam Friends API」的最小可运行工程,用 C++ 写成。它把 Steam 好友系统里最常用的几个能力——好友列表拉取、在线状态、富媒体信息(Rich Presence)、邀请与加入——拆成独立模块,每个模块都能单独跑起来验证。适合两类人:一是刚接触 UE4 联机、想找个能直接编译的参考工程的新手;二是已经在做联机功能、但被 Steam 回调时序和 Overlay 集成卡住的熟手。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆一遍。
2. 工程结构与技术选型:为什么是 C++ 而不是蓝图
2.1 目录分层与模块职责
这个工程没有把 Steam 逻辑塞进一个 GameInstance 里了事,而是按职责拆成了三层。第一层是SteamFriendsDemo主模块,负责游戏逻辑和 UI 驱动;第二层是SteamIntegration插件模块,封装所有 Steamworks SDK 的调用;第三层是ThirdParty/Steamworks目录,放 SDK 的静态库和头文件。这样拆的好处是,Steam 相关的代码全部隔离在插件里,主模块不直接依赖steam_api.h,将来换平台或者升级 SDK 版本,改动范围可控。
目录结构大致是这样:
SteamFriendsDemo/ ├── Source/ │ ├── SteamFriendsDemo/ # 主游戏模块 │ │ ├── SteamFriendsDemo.Build.cs │ │ ├── SteamFriendsDemoGameMode.cpp │ │ └── UI/ │ │ └── FriendsListWidget.cpp │ └── SteamIntegration/ # Steam 封装插件 │ ├── SteamIntegration.Build.cs │ ├── Public/ │ │ ├── SteamSubsystem.h │ │ └── SteamTypes.h │ └── Private/ │ ├── SteamSubsystem.cpp │ └── SteamCallbackManager.cpp ├── ThirdParty/ │ └── Steamworks/ │ ├── Include/ │ └── Lib/ └── Config/ └── DefaultEngine.ini主模块的Build.cs里通过PublicDependencyModuleNames依赖SteamIntegration,而SteamIntegration的Build.cs里再通过PublicIncludePaths和PublicAdditionalLibraries把 Steamworks SDK 引进来。这种依赖方向是单向的,主模块知道插件,插件不知道主模块,符合 UE 的模块规范。
2.2 为什么用 C++ 而不是蓝图
Steamworks 的 API 是纯 C++ 接口,回调机制依赖STEAM_CALLBACK宏和CSteamID这类结构体。蓝图虽然能通过UFUNCTION(BlueprintCallable)暴露一部分功能,但回调注册、CallResult和Callback的区分、以及RunCallbacks的调用时机,在蓝图里很难表达清楚。这个工程选择 C++ 为主、蓝图只做 UI 展示,是合理的。具体来说,SteamSubsystem继承自UGameInstanceSubsystem,在Initialize里完成 SteamAPI 初始化,在Deinitialize里做SteamAPI_Shutdown,生命周期和 GameInstance 对齐,避免手动管理。
2.3 关键依赖与构建配置
SteamIntegration.Build.cs里需要处理三件事:头文件路径、库文件路径、以及运行时 DLL 的拷贝。常见做法是用PublicIncludePaths.Add加头文件目录,用PublicAdditionalLibraries.Add加steam_api64.lib,然后在RuntimeDependencies.Add里把steam_api64.dll标记为运行时依赖,这样打包时会自动拷到输出目录。
// SteamIntegration.Build.cs public class SteamIntegration : ModuleRules { public SteamIntegration(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" }); string SteamSDKPath = Path.Combine(ModuleDirectory, "../../ThirdParty/Steamworks"); PublicIncludePaths.Add(Path.Combine(SteamSDKPath, "Include")); if (Target.Platform == UnrealTargetPlatform.Win64) { PublicAdditionalLibraries.Add( Path.Combine(SteamSDKPath, "Lib", "Win64", "steam_api64.lib")); RuntimeDependencies.Add( "$(BinaryPath)/steam_api64.dll", Path.Combine(SteamSDKPath, "Lib", "Win64", "steam_api64.dll")); } } }这里RuntimeDependencies.Add的第二个参数是源文件路径,第一个参数是目标路径。$(BinaryPath)是 UE 的宏,指向可执行文件所在目录。如果不加这一行,编辑器里能跑,打包后就会因为找不到 DLL 而崩溃,这是最常见的翻车点之一。
3. 核心模块拆解:从 SteamAPI 初始化到好友列表刷新
3.1 SteamSubsystem 的初始化与关闭
SteamAPI 的初始化必须在使用任何 Steam 接口之前完成,而且只能初始化一次。这个工程把初始化放在UGameInstanceSubsystem::Initialize里,用SteamAPI_Init()的返回值判断是否成功。失败时通过FMessageDialog弹窗提示,而不是静默失败,方便定位问题。
// SteamSubsystem.cpp void USteamSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); if (SteamAPI_Init()) { bSteamInitialized = true; UE_LOG(LogTemp, Log, TEXT("SteamAPI 初始化成功,AppID: %d"), SteamUtils()->GetAppID()); // 注册好友状态变化回调 FriendStateChangedCallback.Register(this, &USteamSubsystem::OnFriendStateChanged); } else { bSteamInitialized = false; UE_LOG(LogTemp, Error, TEXT("SteamAPI 初始化失败,请检查 steam_appid.txt")); FMessageDialog::Open(EAppMsgType::Ok, FText::FromString(TEXT("Steam 初始化失败,请确认 Steam 客户端已运行"))); } } void USteamSubsystem::Deinitialize() { if (bSteamInitialized) { FriendStateChangedCallback.Unregister(); SteamAPI_Shutdown(); bSteamInitialized = false; } Super::Deinitialize(); }SteamAPI_Init()内部会读取steam_appid.txt或者从 Steam 客户端获取 AppID。在编辑器里调试时,需要在可执行文件同级目录放一个steam_appid.txt,内容是你的 AppID(比如480是 Spacewar 测试用)。打包后如果通过 Steam 客户端启动,可以不需要这个文件,但开发阶段建议保留。
3.2 好友列表拉取与状态同步
好友列表通过SteamFriends()->GetFriendCount(k_EFriendFlagImmediate)获取数量,再逐个用GetFriendByIndex拿到CSteamID,然后查名字和状态。这个过程是同步的,但状态变化是异步的,所以需要注册PersonaStateChange_t回调。
// 拉取好友列表 TArray<FSteamFriendInfo> USteamSubsystem::GetFriendList() const { TArray<FSteamFriendInfo> Result; if (!bSteamInitialized) return Result; int32 FriendCount = SteamFriends()->GetFriendCount(k_EFriendFlagImmediate); for (int32 i = 0; i < FriendCount; ++i) { CSteamID FriendID = SteamFriends()->GetFriendByIndex(i, k_EFriendFlagImmediate); FSteamFriendInfo Info; Info.SteamID = FriendID.ConvertToUint64(); Info.PersonaName = UTF8_TO_TCHAR(SteamFriends()->GetFriendPersonaName(FriendID)); Info.State = static_cast<E SteamPersonaState>( SteamFriends()->GetFriendPersonaState(FriendID)); Result.Add(Info); } return Result; } // 状态变化回调 void USteamSubsystem::OnFriendStateChanged(PersonaStateChange_t* Param) { CSteamID ChangedID(Param->m_ulSteamID); OnFriendStateChangedDelegate.Broadcast(ChangedID.ConvertToUint64()); }k_EFriendFlagImmediate表示只拉取「好友」而不是「好友的好友」。GetFriendPersonaName返回的是 UTF-8 字符串,必须用UTF8_TO_TCHAR转换,否则中文名会乱码。PersonaStateChange_t回调里拿到的是变化的 SteamID,UI 层收到广播后只刷新对应条目,而不是整个列表重刷,这样在好友多的时候性能更好。
3.3 Rich Presence 与邀请加入
Rich Presence 用来在好友列表里显示「正在玩什么模式」,通过SetRichPresence设置键值对。邀请功能则用ActivateGameOverlayInviteDialog打开 Steam 的邀请面板,或者用InviteUserToGame直接邀请指定好友。
void USteamSubsystem::SetRichPresence(const FString& Key, const FString& Value) { if (!bSteamInitialized) return; SteamFriends()->SetRichPresence( TCHAR_TO_UTF8(*Key), TCHAR_TO_UTF8(*Value)); } void USteamSubsystem::InviteFriend(uint64 SteamID) { if (!bSteamInitialized) return; CSteamID FriendID(SteamID); SteamFriends()->InviteUserToGame(FriendID, "join_demo"); }InviteUserToGame的第二个参数是命令行参数,对方接受邀请后,游戏启动时会带上这个参数。在GameMode的InitGame里解析这个参数,就能实现「点击邀请直接进房间」的流程。注意SetRichPresence的键值对需要提前在 Steamworks 后台配置好,否则不会显示。
4. 跑起来:编译、配置与联机验证的完整步骤
4.1 环境准备与 SDK 放置
先把 Steamworks SDK 下载下来,解压后把public目录改名为Include,把redistributable_bin/win64里的steam_api64.lib和steam_api64.dll放到ThirdParty/Steamworks/Lib/Win64/下。目录结构要和前面Build.cs里写的路径一致,否则编译会报找不到头文件。
然后在项目根目录的Config/DefaultEngine.ini里加上 Steam 相关的配置:
[/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 bUseSteamNetworking=trueSteamDevAppId=480是 Spacewar 的测试 AppID,正式项目要换成自己的。bUseSteamNetworking=true表示用 Steam 的网络层而不是 IP 直连,这样邀请和加入才能正常工作。
4.2 编译与启动顺序
编译时先右键.uproject生成工程文件,然后用 Visual Studio 打开SteamFriendsDemo.sln,选择Development Editor配置编译。编译通过后,在编辑器里运行之前,确保 Steam 客户端已经登录。如果没登录,SteamAPI_Init()会失败并弹窗。
启动顺序很重要:先开 Steam 客户端,再启动编辑器或打包后的 exe。如果先启动游戏再开 Steam,初始化会失败,需要重启游戏。这是 Steamworks 的机制决定的,不是 bug。
4.3 双端联机验证
验证邀请功能需要两个 Steam 账号。常见做法是在两台机器上分别登录不同账号,或者用同一台机器上的两个 Windows 用户切换。启动游戏后,在好友列表里找到对方,点击邀请,对方会收到 Steam 的邀请通知,接受后自动加入。
如果邀请没反应,先检查steam_appid.txt是否存在且 AppID 一致,再检查DefaultEngine.ini里的DefaultPlatformService是否为Steam。还有一个容易忽略的点:OnlineSubsystemSteam模块需要在.uproject的Plugins里启用,否则配置不生效。
5. 避坑与排查:几个让我熬夜的常见问题
5.1 初始化失败但日志没有详细信息
现象是SteamAPI_Init()返回 false,日志里只有一行「初始化失败」。原因是 Steam 客户端没运行,或者steam_appid.txt缺失。解决方法是先确认 Steam 客户端已登录,再检查 exe 同级目录是否有steam_appid.txt,内容为纯数字 AppID。如果还不行,用SteamAPI_RestartAppIfNecessary强制通过 Steam 启动。
5.2 打包后 Overlay 不显示
现象是编辑器里 Overlay 正常,打包后按 Shift+Tab 没反应。原因是steam_api64.dll没有被拷到打包输出目录。解决方法是确认Build.cs里RuntimeDependencies.Add的路径正确,打包后去Binaries/Win64/下看 DLL 在不在。另外 Overlay 需要 Steam 客户端里该游戏的状态为「运行中」,如果 exe 不是通过 Steam 启动的,Overlay 可能不注入。
5.3 好友列表中文名乱码
现象是好友名字显示成问号或方块。原因是GetFriendPersonaName返回 UTF-8,直接赋给FString会按 ANSI 解析。解决方法是用UTF8_TO_TCHAR包一层,或者用FString(UTF8_TO_TCHAR(...))构造。这个坑在中文用户名下必现。
5.4 回调不触发
现象是注册了PersonaStateChange_t但好友上下线时没反应。原因是SteamAPI_RunCallbacks()没有被定期调用。在 UE 里,OnlineSubsystemSteam会自动处理,但如果你直接调 Steamworks 原生接口,需要在Tick里手动调SteamAPI_RunCallbacks()。这个工程的SteamSubsystem通过FTSTicker注册了一个每帧回调来驱动。
5.5 邀请后对方加入失败
现象是对方接受邀请但进不来,日志显示连接超时。原因是DefaultEngine.ini里bUseSteamNetworking没开,或者SteamNetDriver配置写错。解决方法是确认NetDriverDefinitions里的DriverClassName指向OnlineSubsystemSteam.SteamNetDriver,并且OnlineSubsystemSteam模块已启用。
6. 二次开发:把演示工程改造成自己的联机模块
这个工程的价值不在于它本身能玩,而在于它是一块可以拆的积木。我一般会按下面的顺序把它嵌进正式项目。
第一步,把SteamIntegration插件整个拷到目标项目的Plugins/目录下,改掉模块名和Build.cs里的路径。注意PublicDependencyModuleNames里要加上目标项目需要的模块,比如UMG如果 UI 层要用。
第二步,把SteamSubsystem里的GetFriendList改成异步版本。演示工程是同步拉取,好友多的时候会卡主线程。常见做法是用SteamFriends()->RequestFriendRichPresence配合FSteamFriendInfo的缓存,在回调里增量更新。下面是一个改造后的接口示例:
// 异步拉取,避免主线程卡顿 void USteamSubsystem::RequestFriendListAsync() { AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]() { TArray<FSteamFriendInfo> List = GetFriendList(); AsyncTask(ENamedThreads::GameThread, [this, List]() { OnFriendListReadyDelegate.Broadcast(List); }); }); }这里用AsyncTask把拉取放到后台线程,再切回游戏线程广播。注意 Steamworks 的接口不是线程安全的,GetFriendList内部只读,放后台问题不大,但SetRichPresence这类写操作必须在游戏线程调。
第三步,把 Rich Presence 的键值对做成数据表驱动。演示工程里是硬编码的"join_demo",正式项目里我会用一个UDataTable存不同模式对应的参数,邀请时根据当前模式查表。这样加新模式不用改代码。
第四步,验证改造是否成功。我的习惯是每次改完SteamSubsystem,先跑一遍「初始化 → 拉好友 → 设 Rich Presence → 邀请 → 接受」这条链路,确认每一步的日志都正常。特别是SteamAPI_Shutdown之后有没有残留回调,这个在热重载时容易出问题。
从那以后我每次接 Steam 功能,都强制先跑一遍这个演示工程的完整流程,确认 SDK 版本、DLL 路径和 AppID 配置都对,再往正式项目里搬。希望帮到你。
本文还有配套的精品资源,点击获取