今年早些时候,《Dodo Peak》有幸作为苹果新的Arcade订阅服务的首发作品之一发行。苹果Arcade的主要卖点之一是,游戏可以在包括Mac在内的各种设备上运行。为了在苹果Arcade上发行《Dodo Peak》,我们必须与苹果的Mac应用商店生态系统以及iCloud和Game Center等苹果服务集成。
任何打算发行Mac版游戏的人都应该考虑做这样的集成,这有很好的理由:
- 在Mac应用商店,你的游戏可以有组织地吸引到那些不习惯去偏重游戏的数字商城的玩家。
- 使用iCloud或Game Center处理排行榜和云存储之类的事务,意味着你可以依靠免费可信的第一方服务,而不必将第三方基础结构集成到你的应用。
- 苹果Arcade需要Game Center和iCloud,这是它允许跨设备访问云存储的承诺的一部分。
你将会高兴地发现,虚幻为iOS目标提供对Game Center和iCloud的现成支持。坏消息是,针对macOS的构建版没有内置同样的支持。直到最近,以Mac应用商店作为虚幻游戏的发行目标仍然是比较少见的用例。
好在任何人都可以自己动手来绕过这些功能鸿沟。最近几个月里,我无意中成了在Mac上将虚幻游戏与苹果服务集成的专家,我愿意将我的知识倾囊相授,让你不必自行摸索。本指南将介绍如何将你的Mac游戏与苹果服务集成,以及如何针对Mac应用商店打包你的游戏。
在苹果开发者门户中设置你的Mac应用
要让你的游戏能够利用苹果服务并在Mac应用商店上架,第一步是在苹果的后端中配置你的应用。许多指南都会告诉你怎么做这一步,但是为了完整起见,下面简单列出你必须执行的操作:- 加入苹果开发者计划(年费是99美元)。
- 通过“证书、标识和配置文件(Certificates, Identifiers & Profiles)”页面上的“标识(Identifiers)”部分创建你的应用。
- 给你的应用提供一个有用的包标识,例如“
com.mycompany.mygame
”。 - 启用iCloud和Game Center功能,以及你需要的其他任何功能。
- 注:如果你的游戏的iOS版已经有一个在使用的应用标识,你可以从iOS应用标识的设置中选择“Mac”功能来自动生成Mac标识。
- 给你的应用提供一个有用的包标识,例如“
- 在“证书、标识和概要文件(Certificates, Identifiers & Profiles)”页面的“证书(Certificates)”部分中创建你的帐户证书。
- 在本地创建一个证书签名请求。
- 使用它创建和下载用于下列用途的证书:
- Mac开发
- Mac应用分发
- Mac安装程序分发
- 在“证书、标识和概要文件(Certificates, Identifiers & Profiles)”页面的“设备(Devices)”部分中添加你拥有的任何测试或开发Mac。
- 你可以通过系统报告功能找到Mac UDID。
- (可选)通过“证书、标识和概要文件(Certificates, Identifiers & Profiles)”页面的“标识(Identifiers)”部分创建一个iCloud容器。
- 编辑你的应用标识,使用iCloud功能旁边的按钮指定该容器。
- 创建和下载相关配置概要文件。
- 创建一个Mac开发配置概要文件。
- 一定要选择你新创建的应用标识、证书和设备。
- 创建一个Mac应用商店配置概要文件。
- 创建一个Mac开发配置概要文件。
- 在应用商店连接中创建一个新的macOS应用,选择你的新包标识。
- 在虚幻引擎中打开你的项目。在项目设置中,转到“平台(Platforms)> iOS”,然后将包标识设置为新的包标识。
这是很无聊的部分。但是以后当你打包或测试游戏时,这些操作都会给你提供方便。下一节将介绍怎样围绕Game Center和iCloud等功能编写代码。
将Mac游戏与苹果API集成
由于虚幻引擎没有让游戏在macOS上与iCloud或Game Center通话的挂钩,所以你必须自己实现它们。快速浏览苹果文档后我们发现,要砸开其操作系统API的外壳,吃到营养丰富的核心,唯一的办法就是使用Objective-C或Swift。这听起来有点吓人,因为虚幻使用的是C++。那么你要怎么做?打算千辛万苦地编译和链接静态库吗?不!
你可以使用虚幻引擎源代码所用的方法来绕过这个障碍:使用一种功能强大但鲜为人知的Xcode功能,它叫做Objective-C++。
什么是Objective-C++?
顾名思义,Objective-C++就是在你的C++内部内联Objective-C代码(反之亦可)的功能。C++和Objective-C都是C的超集,也就是说它们支持原版C语言的所有语法,还有附加的功能。虽然这两种语言在有些情况下会冲突,但大多数时候,在Xcode中同时使用它们是行之有效的。对那些想看更深入的Objective-C++指南的人,我推荐Medium的这篇文章。Objective-C采用基于引用计数的内存管理,有一些相关的风险需要避免,不过大多数时候不会出问题。现在来看你最想从本文得到的东西:代码范例。
在Mac上支持Game Center
苹果有关于如何针对Game Center编码的官方指南,简单来说就是这样:- 对玩家进行身份验证
- 使用通过身份验证的玩家,对成就、排行榜等进行调用
要编译以下任何代码,你需要先通过虚幻的构建系统向你的软件包公开必需库。在游戏的Build.cs文件中,向构造函数添加下列逻辑:
if (Target.Platform == UnrealTargetPlatform.Mac) {
PublicFrameworks.AddRange(new string[]{"GameKit"});
}
这里是一个使用Objective-C++验证玩家身份的范例。请注意,我将一个Objective-C回调函数传递到authenticateHandler,但是这个回调函数中的代码可以包含C++。
#include <GameKit/GameKit.h>
void UMyBlueprintFunctionLibrary::GameCenterSignIn() {
#if WITH_EDITOR
// do not run in editor
#elif PLATFORM_MAC
// asynchronously log into GameCenter.In your code I recommend wiring this up
// with delegates or UBlueprintAsyncActionBase so that you can handle cases
// where logging in takes a while.
dispatch_async(dispatch_get_main_queue(), ^{
[[GKLocalPlayer localPlayer]
setAuthenticateHandler:^(NSViewController *_Nonnull viewController, NSError *error) {
if ([[GKLocalPlayer localPlayer] isAuthenticated])
{
// Success
return;
}
if (error)
{
// Failure
}
else if (viewController)
{
// display login
GKDialogController *presenter = [GKDialogController sharedDialogController];
presenter.parentWindow = [NSApp keyWindow];
[presenter presentViewController:(NSViewController * _Nonnull) viewController];
}
}];
});
#endif
}
这里是一些解锁成就的示例代码。
#include <GameKit/GameKit.h>
void UMyBlueprintFunctionLibrary::WriteAchievement(FString ID, float Percent)
{
#if PLATFORM_MAC
if (![[GKLocalPlayer localPlayer] isAuthenticated])
return;
// convert FString to NSString
NSString *nsID = [NSString stringWithUTF8String:TCHAR_TO_ANSI(*ID)];
GKAchievement *achievement = [[GKAchievement alloc] initWithIdentifier:nsID];
achievement.percentComplete = Percent;
achievement.showsCompletionBanner = YES;
[GKAchievement reportAchievements:@[ achievement ]withCompletionHandler:^(NSError *error) {
if (error != nil)
{
NSLog(@"%@", [error localizedDescription]);
}
}];
#endif
}
这里是调用Game Center的排行榜API的代码。
#include <GameKit/GameKit.h>
void UMyBlueprintFunctionLibrary::WriteScoreToLeaderboard(FString LeaderboardID, int Integer) {
#if PLATFORM_MAC
if (![[GKLocalPlayer localPlayer] isAuthenticated])
return;
// convert FString to NSString
NSString *nsID = [NSString stringWithUTF8String:TCHAR_TO_ANSI(*LeaderboardID)];
GKScore *score = [[GKScore alloc] initWithLeaderboardIdentifier:nsID];
score.value = Integer;
[GKScore reportScores:@[score] withCompletionHandler:^(NSError *error) {
if (error != nil)
{
NSLog(@"%@", [error localizedDescription]);
}
}];
#endif
}
这是最后的警告:在虚幻编辑器中从你的代码调用Game Center函数可能导致一些奇怪的行为,所以我建议你把Game Center代码打包在
#if WITH_EDITOR
宏中,或用其他方式在游戏的解包版本中禁用这些功能。使用iCloud实现云存档
本指南不会解释iCloud的所有奥秘。苹果有一些基础教程,你应该先阅读它们,再着手使用iCloud为你的作品添加云存档支持。根据你的用途,可以将iCloud视作一个很大的键-值存储,玩家可以对它进行读写。有几种方法可以为iCloud建模存档数据:
- 将存档对象的每个字段作为它在iCloud中的自有字段(例如,玩家的等级字段将是一个名为“Level”的整数,而他们的角色名称将是名为“Name”的字符串,等等)。
- 将存档对象序列化为二进制数据,并将整个存档作为一个二进制Glob(###)上传到一个iCloud字段。
对于《Dodo Peak》,我的团队决定采用第2种选择,因为这允许我们使用虚幻用来将存档写出至磁盘的那种序列化逻辑。
还是一样,在运行代码之前,我们必须先告诉构建系统使用CloudKit库:
if (Target.Platform == UnrealTargetPlatform.Mac) {
PublicWeakFrameworks.Add("CloudKit");
}
这里是一些序列化SaveGame对象并将其上传至iCloud的示例代码:
#include <CloudKit/CloudKit.h>
#include "GameFramework/SaveGame.h"
#include "Kismet/GameplayStatics.h"
#include "Serialization/MemoryReader.h"
#include "Serialization/MemoryWriter.h"
#include "Serialization/ObjectAndNameAsStringProxyArchive.h"
void UMyBlueprintFunctionLibrary::WriteSaveToCloud(USaveGame* MySave) {
TArray<uint8> ObjectBytes;
FMemoryWriter MemoryWriter(ObjectBytes, true);
FObjectAndNameAsStringProxyArchive Ar(MemoryWriter, false);
MySave->Serialize(Ar);
CKContainer *defaultContainer =
[CKContainer containerWithIdentifier:@"iCloud.unrealtutorial.mygame"];
if (defaultContainer == nil)
{
// initialization failed
return;
}
else
{
CKDatabase *DB = [defaultContainer privateCloudDatabase];
CKRecordID *recordId = [[[CKRecordID alloc] initWithRecordName:@"save_game_id"] autorelease];
// RecordType "SaveGame" is configured in Apple's online iCloud dashboard
CKRecord *record = [[CKRecord alloc] initWithRecordType:@"SaveGame" recordID:recordId];
// Convert Unreal data array to NSData bytes
NSData *data = [NSData dataWithBytes:ObjectBytes.GetData() length:ObjectBytes.Num()];
record[@"SaveData"] = data;
// use CKModifyRecordsOperation to allow updating existing records
CKModifyRecordsOperation *modifyRecords =
[[CKModifyRecordsOperation alloc] initWithRecordsToSave:@[ record ]
recordIDsToDelete:nil];
modifyRecords.savePolicy = CKRecordSaveAllKeys;
modifyRecords.qualityOfService = NSQualityOfServiceUserInitiated;
modifyRecords.perRecordCompletionBlock = ^(CKRecord *results, NSError *error) {
if (error != nil)
{
NSLog(@"icloud save error:%@", error);
}
else
{
NSLog(@"icloud save success:%@", results);
}
};
[DB addOperation:modifyRecords];
}
}
这里是从iCloud加载SaveGame数据并将二进制数据读取到新的SaveGame对象的示例代码:
#include <CloudKit/CloudKit.h>
#include "GameFramework/SaveGame.h"
#include "Kismet/GameplayStatics.h"
#include "Serialization/MemoryReader.h"
#include "Serialization/MemoryWriter.h"
#include "Serialization/ObjectAndNameAsStringProxyArchive.h"
UCustomSaveGame* UMyBlueprintFunctionLibrary::LoadFromCloud() {
CKContainer *defaultContainer =
[CKContainer containerWithIdentifier:@"iCloud.unrealtutorial.mygame"];
if (defaultContainer == nil)
{
return nullptr;
}
else
{
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"TRUEPREDICATE"];
CKDatabase *publicDatabase = [defaultContainer privateCloudDatabase];
CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:@"save_game_id"];
__block NSData *data = nil;
// hold onto results in this block to keep data from being GC'd from under us
__block CKRecord *holdResults;
// NOTE: this should be synchronized using delegates, semaphores, or some other means
[publicDatabase
fetchRecordWithID:recordID
completionHandler:^(CKRecord *results, NSError *error) {
holdResults = results;
if (error != nil)
{
NSLog(@"icloud load error:%@", error);
}
else
{
NSLog(@"icloud load success:%@", results);
data = [[NSData alloc] initWithData:(NSData *) results[@"SaveData"]];
}
}];
// DO NOT SYNCHRONIZE WITH A SLEEP -- this is for example purposes only
usleep(3000000); // wait for async load
// read data into a save object
TArray<uint8> ObjectBytes;
ObjectBytes.AddUninitialized(data.length);
FMemory::Memcpy(ObjectBytes.GetData(), data.bytes, data.length * sizeof(uint8));
FMemoryReader MemoryReader(ObjectBytes, true);
FObjectAndNameAsStringProxyArchive Ar(MemoryReader, true);
UCustomSaveGame *LoadedSave = Cast<UCustomSaveGame>( UGameplayStatics::CreateSaveGameObject(UCustomSaveGame::StaticClass()));
LoadedSave->Serialize(Ar);
return LoadedSave;
}
}
以上代码只是给你提供一个起点。每个人的需求都是不同的,要为你的游戏完全实现云存档,你必须考虑的事项包括:
- 你的游戏存档频率有多高?你读档的频率有多高?你应该在每次存读档时都与云端通信,还是只在有些时候这样做?
- 你是否需要支持多种设备读写同一个iCloud记录?
- 你怎样处理离线进度?
- 你的游戏将如何处理玩家的本地存档与云端数据冲突的情况?
请注意,如果你从编辑器或在导出你的应用之后尝试运行任何Mac iCloud代码,你的游戏将会抛出异常,抱怨缺少授权。和Game Center一样,我建议你不要让任何iCloud代码在编辑器中运行。
下一节概述如何在你的开发构建版板中真正运行那些iCloud代码。
签名和打包你的游戏
该把所有这些东西打包了。只要翻过旅途中的最后一个山头,你就能让你的虚幻游戏使用苹果API并把它上传到Mac应用商店了。为此你需要配置游戏的代码签名、授权和Plist。光是这些话题就可以写一篇博文,这里仅作快速总结。
- 代码签名是苹果确保可执行文件代码真正出自原作者之手而且未经篡改的方法。在这里可以了解更多信息。
- 授权是在代码签名时嵌入到应用中的键值对,它指示了你的应用可以利用的某些安全操作系统功能,例如iCloud。在这里可以了解更多信息。
- 信息属性列表(即“Info.plist”)包含对你的应用以及应用商店必不可少的配置数据,包括应用图标的位置或支持的语言等信息。在这里可以了解更多信息。
如果你熟悉虚幻引擎如何使用针对iOS的配置概要文件和证书打包的话,那么这个过程和它很相似,只不过你必须自己执行通常虚幻和Xcode为你代劳的步骤。
用于本地开发的打包和签名
你知道,在将游戏推送到应用商店之前,你很可能需要确保它能运行。那么怎样制作一个可以本地运行的构建版?前面提到过,如果你尝试调用iCloud函数,而没有用iCloud授权对你的应用程序签名,游戏将会崩溃。所以让我们来补上这些缺失的授权。这个过程简单直接,但很麻烦。要将新的授权引入Mac应用程序中,你需要为该应用提供一个配置概要文件,然后用必要的授权对它签名。下面是操作方法。
在虚幻引擎中,将你的游戏打包。然后在一个终端上,导航到包含新导出的应用的目录。创建开发配置概要文件的副本,然后将它重命名为“
embedded.provisionprofile
”。将这个配置概要文件复制到“YourGame.app/Contents/embedded.provisionprofile
”。要为它提供新的代码签名,首先你必须创建一个XML文件,其中应有你要添加到应用程序的授权。该文件可能类似于以下示例:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>TEAMID.com.mycompany.mygame</string>
<key>com.apple.developer.icloud-container-environment</key>
<string>Development</string>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.mycompany.mygame</string>
</array>
<key>com.apple.developer.game-center</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict>
</plist>
确保更改以上XML,使得:
- 你的
com.apple.application-identifier
就是你的完整应用包标识(重要信息:确保包标识以你的团队标识开头,它是字母和数字组成的字符串,类似于“3Y1CL48M1K”)。 - 更改
com.apple.developer.icloud-container-identifiers
数组,使其包含你的iCloud容器标识。
将这个文本文件另存为“
entitlements.plist
”。(顺便说一下,你可以使用
codesign -dv --entitlements - AppName.app
来查看任何应用程序的授权,这个命令在调试时很方便。)为了正确为应用签名,你还必须先为所有可执行文件代码签名,然后才能为应用本身签名。你可以使用一个命令来执行所有这些签名:
codesign --deep -f -v -s "3rd Party Mac Developer:"--entitlements entitlements.plist MyGame.app
这就行了!在Finder中双击你的应用,这时你应该就能玩游戏并提出iCloud和Game Center请求了!
用于分发的打包和签名
将应用发送到Mac应用商店须遵循同样的步骤,但略微复杂一些。请手动将下面的步骤执行一遍来理解它们,然后编写你自己的自动执行脚本。首先,将你的游戏作为启用分发的发售软件包导出。
然后,编辑你的plist,添加可能要发送到商店页面的任何附加信息。例如,你可以通过GCSupportedGameControllers列出游戏支持的控制器,使用CFBundleLocalizations设置游戏支持的语言(应用商店无法自动检测虚幻的本地化支持),并且手动编辑版本号和包标识。必须设置应用的LSApplicationCategoryType,使它能被Application Loader接受。
复制你的分发配置概要文件,然后将它重命名为“
embedded.provisionprofile
”。将该配置概要文件复制到“YourGame.app/Contents/embedded.provisionprofile
”。现在,和分发构建版签名不同的是,我们必须对导出的.app做些小清理,才能让它被Mac应用商店批准。
首先,Mac应用商店不支持32位可执行文件代码(macOS 10.15版和更高版本也不支持)。虚幻会自动将一些包含32位代码的动态音频库与你的游戏捆绑到一起,所以我们必须删除那些代码。好在这是计算机科学史上相当常见的问题,因此有专门做这些操作的工具。你可以利用lipo命令,运行:
lipo MyGame.app/Contents/UE4/Engine/Binaries/ThirdParty/Ogg/Mac/libogg.dylib -remove i386 -output MyGame.app/Contents/UE4/Engine/Binaries/ThirdParty/Ogg/Mac/libogg.dylib
lipo MyGame.app/Contents/UE4/Engine/Binaries/ThirdParty/Vorbis/Mac/libvorbis.dylib -remove i386 -output MyGame.app/Contents/UE4/Engine/Binaries/ThirdParty/Vorbis/Mac/libvorbis.dylib
lipo MyGame.app/Contents/UE4/Engine/Binaries/ThirdParty/OpenVR/OpenVRv1_0_16/osx32/libopenvr_api.dylib -remove i386 -output MyGame.app/Contents/UE4/Engine/Binaries/ThirdParty/OpenVR/OpenVRv1_0_16/osx32/libopenvr_api.dylib
其次,虚幻会将一个子组件添加到你导出的游戏,而它已经有自己的包标识,这会让Application Loader抓狂。这个组件是“
RadioEffectUnit.component
”。据我所知,这是一个音频效果,但是我不能确定为什么构建系统要这样处理它。好在如果你不使用RadioEffectUnit,就可以直接从应用删除它:
rm -rf MyGame.app/Contents/Resources/RadioEffectUnit.component
rm -rf MyGame.app/Contents/UE4/Engine/Build
完成这些清理以后,我们就可以继续为构建版签名了。
为了准备代码签名,首先你必须创建一个XML文件,其中应有你要添加到应用程序的授权。该文件可能类似于以下示例:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>TEAMID.com.mycompany.mygame</string>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.mycompany.mygame</string>
</array>
<key>com.apple.developer.game-center</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict>
</plist>
确保更改以上XML,使得:
- 你的
com.apple.application-identifier
就是完整应用包标识(重要信息:确保包标识以你的团队标识开头,它是字母和数字组成的字符串,类似于“3Y1CL48M1K”)。 - 更改
com.apple.developer.icloud-container-identifiers
,使其包含你的iCloud容器标识。
将这个文本文件另存为“
entitlements.plist
”。现在该签名了!为了进行分发,你必须为游戏二进制文件和所有动态库签名,最后还要为.app本身签名。我们的做法是:
codesign -f -v -s "3rd Party Mac Developer Application:"--entitlements entitlements.plist MyGame.app/Contents/MacOS/MyGame
这个命令会使用
find
为文件中的所有动态库(.dylib)签名:
find MyGame.app/Contents/ | grep .dylib | xargs codesign -f -v -s "3rd Party Mac Developer Application:"--entitlements entitlements.plist
然后为整个应用签名:
codesign -f -v -s "3rd Party Mac Developer Application:"--entitlements entitlements.plist MyGame.app/
全部签名完毕后,我们可以打包应用了。要生成可上传的.pkg,运行:
productbuild --component MyGame.app/ /Applications --sign "3rd Party Mac Developer Installer:"MyGame.pkg
现在打开你系统上的Application Loader应用。单击“选择(choose)”按钮,然后选择你的新.pkg文件。Application Loader会扫描你的软件包中有无错误,然后将它上传到应用商店连接门户。
常见的打包问题
最后,我要指出几个你在打包过程中可能会遇到的问题。首先,如果你遇到任何关于打包的问题,虚幻论坛上关于这些问题的贴子是:https://forums.unrealengine.com/community/community-content-tools-and-tutorials/68346-how-to-create-the-proper-pkg-file-for-deployment-to-the-macstore/page2
其次,Application Loader可能会拒绝你的应用,理由是“ERROR ITMS-90135:The executable could not be re-signed for submission to the App Store.The app may have been built or signed with non-compliant or pre-release tools”。这条错误消息是真正的噩梦,因为它基本上就意味着“出错了,但我们没法告诉你是怎么回事”。我的团队为这个问题忙了几天,最后我们发现,原来是我们在构建版中包含了调试符号,它影响了应用的处理。务必在你的项目设置中取消选择“包含调试文件(Include Debug Files)”。
最后,我们发现最常见的Application Loader拒绝理由和我们的应用程序图标有关。有各种工具和技巧可以用来生成符合规范的.icns文件,但是我们在工作流程中最后使用了App Wrapper 3,从一个PNG文件生成图标组。