🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
# 简述 静态结构分析中会对Shooter Game中涉及的代码总体结构进行分析,实质上是解答了“Shooter Game 修改/扩展/实现了哪些功能的问题”。而“如何实现对应功能”的问题的答案在下一章动态结构分析中。 [TOC] # 总体结构 Shooter Game的代码行数为26674行,其中纯代码行数为18715行。 工程中只有源代码,没有专门设计的用于定制引擎的插件。 # 模块划分 源代码分为了两个模块:Shooter Game主模块和Shooter Game Loading Screen模块。 > 为什么要拆分两个模块? 将Loading Screen模块单独拆分的原因可以从uproject描述文件得知: ~~~ "FileVersion": 3, "EngineAssociation": "4.15", "Category": "Samples", "Description": "", "Modules": [ { "Name": "ShooterGame", "Type": "Runtime", "LoadingPhase": "Default" }, { "Name": "ShooterGameLoadingScreen", "Type": "Runtime", "LoadingPhase": "PreLoadingScreen" } ], ~~~ 注意观察,两个模块的载入时机并不一致。Loading Screen模块需要在载入画面之前载入,才能修改载入画面。 关于以下问题的回答,请直接阅读《大象无形》一书中关于模块机制的阐述: 1. 什么是模块 2. 如何创建我自己的模块 3. 模块的加载 4. 如何引用其他模块 # 主模块 ## 文件夹结构的整理和划分 Shooter Game对源文件通过文件夹按照功能进行了划分。所以能够很清晰地看出框架如下: * Bots: 机器人AI相关代码 * Effects:特效相关代码 * Online:联机相关代码 * Pickups:可拾取的对象相关代码 * Player:Character、Controller和Movement等与玩家相关的代码 * Sound :音效相关代码 * UI:UI相关代码 * Weapon:武器相关代码 罗列模块名的意义是让手边没有源码的读者能够从大体上了解Shooter Game从程序的角度如何划分模块,并能够顺畅阅读接下来的详细分析。 ## 玩家相关代码 下文将会对玩家相关的代码进行静态的分析,主要目的是回答“这个类在做什么”,而不是回答“这个类如何完成具体实现”,希望读者注意这一点。 如果读者对虚幻引擎的玩家框架不是非常了解,此处给出一个简单的解释: ![](https://box.kancloud.cn/49aaf780bd47a3d5bef98fc344346012_243x461.png) PlayerController代表玩家输入和操作的抽象类,而APawn代表被操纵的实体(Pawn的含义是兵卒或者国际象棋棋子)。 PlayerController继承自Controller,Controller可以通过Possess函数操纵一个特定的Pawn,或者通过Unpossess函数脱离操纵某个Pawn。 这样的设计允许玩家在不同的Pawn之间切换,例如在活着的时候操作一个第一人称的Pawn,在死去后操作一个只能飞行的幽灵Pawn。 Shooter Game同样遵循了这样的设计范式。 ### Character 对虚幻引擎游戏性编程基本范式有一定了解的朋友,一定知道对玩家操控的人物对象的定义是通过继承Character类实现。Shooter Game也不例外,其继承了自己的Character类。 Character类的复杂度相当的高,故采用几个层面进行解析: #### Character语义 从语义上说,一个Character应该是场景中的“角色”的**实体**描述。 1. 应当能够系统完整地描述角色本身 2. 应当适度剥离与角色关系不大的属性 #### Character数据结构 因此,Shooter Game的Character设计,包含了以下几个基础部分: 1. Character数值特性: 1. 生命值Health 2. 是否正在死亡bIsDying 2. Character表现属性: 1. 死亡动画 2. 死亡声音 3. 重生特效 4. ... 3. Character状态信息: 1. 是否正在瞄准bIsTargeting 2. 是否即将开始跑步:bWantsToRun 3. ... 4. 武器管理相关信息: 1. 武器数组Inventory 2. 当前武器CurrentWeapon #### Character方法(访问器与工具函数) Character在这一套数据结构的基础上,提供了一系列的方法函数。 由于方法函数颇多,此处不再罗列,在动态分析部分会选取部分进行讲解。 #### 一些额外探讨 由于Shooter Game本身的体积,导致其有必要控制代码的复杂程度和抽象程度。在UDK时代的UT3中,采用了一个InventoryManager作为中间层,负责管理大量的武器、装备和各种物品。 究竟哪一种范式更加正确,取决于项目本身的规模。 ### Player Controller Shooter Game继承了自己的Player Controller 类。这个类同Character类一起,包含大量的服务端与客户端的功能。每一个函数的上方注释中,会给出[Client]或者[Server]这样的内容,例如: ~~~ /** [server] spawns default inventory */ void SpawnDefaultInventory(); ~~~ 或是在命名中给出执行位置是在Server还是Client的信息,例如: ~~~ /** sets spectator location and rotation */ UFUNCTION(reliable, client) void ClientSetSpectatorCamera(FVector CameraLocation, FRotator CameraRotation); ~~~ 需要提醒读者的是,虚幻引擎的Server/Client执行方式不是通过注释指定的。而是通过UFUNCTION宏的标记来完成。 ### Cheat Manager 该类为虚幻引擎用于进行调试(作弊)的类,其基类为UCheatManager,根据官方注释,该类不会在发布模式(Shipping)下被实例化: ~~~ /** Cheat Manager is a central blueprint to implement test and debug code and actions that are not to ship with the game. As the Cheat Manager is not instanced in shipping builds, it is for debugging purposes only */ class ENGINE_API UCheatManager : public UObject ~~~ 这个类的大量函数被标记为exec,从而支持从控制台通过输入命令来执行特定的调试函数,例如: ~~~ /** Pawn no longer collides with the world, and can fly */ UFUNCTION(exec,BlueprintCallable,Category="Cheat Manager") virtual void Ghost(); ~~~ 在控制台中输入Chost并回车,能够关闭玩家和世界的碰撞。 ### Spectator Pawn Shooter Game继承了这个类以提供“幽灵/观察者模式”的功能。当Player Controller操纵这个类时,自动进入观察者模式。 ### Shooter Persistent User Shooter Game为了演示持久化系统,所以提供了一个记录当前使用者的成绩记录的功能。 ![](https://box.kancloud.cn/c941cc2a6aef4563bcf57c209bd12dba_223x314.png) 如图所示,UShooterPersistentUser继承自USaveGame。这个类用于进行数据持久化。 基础的使用方式如下: 1. 继承USaveGame创建自己的持久化数据类,在该类中定义需要的数据字段 2. 使用UGameplayStatics::CreateSaveGameObject 创建持久化数据类的实例。该函数需要传入一个继承自USaveGame的类作为模板参数。 3. 使用UGameplayStatics::SaveGameToSlot 保存数据,需要一个FString作为索引。 4. 使用UGameplayStatics::LoadGameFromSlot,以事前拟定的索引读取数据信息 关于USaveGame的更多使用信息,请参考[官方关于SaveGame的文档页面](https://docs.unrealengine.com/latest/INT/Gameplay/SaveGame) Shooter Game通过UShooterPersistentUser类,记录了当前使用者的击杀数量等信息。然后通过ULocalPlayer::GetNickName作为索引来存储。 ## 武器系统与可拾取物系统 武器系统包括了几个核心类: ![](https://box.kancloud.cn/488e8d5c92ece454a541f22f8d39a51d_797x526.png) 该图看上去十分复杂,请读者千万不要看着头疼。下面我一部分一部分地解释。 ### 武器系统的核心:AShooterWeapon > 该用什么样的结构表示武器?是一个UObject的子类,还是一个AActor的子类,还是一个F开头的纯数据类? Epic采用了混合式的设计。首先有一个核心类AShooterWeapon,该类作为一个武器的实体表示。 在《大象无形》一书中,我曾经阐述过,继承Actor的原因是需要挂载组件,而一个武器实体显然需要至少一个StaticMeshComponent静态网格物体组件。因此从语义上说,AShooterWeapon继承自Actor是完全合理的。 > AShooterWeapon的继承模式是如何推导出来的? 假如我们此时暂时不考虑代码,我们不妨想象一下,AShooterWeapon中需要什么样的成员变量以描述。至少我们应该能够想到以下部分: 1. 武器自身的数据描述 1. 武器弹药量 2. 武器单个弹夹数量 3. 武器开火速度 4. 等等... 2. 武器表现性数据描述 1. 武器开火动画 2. 武器装备动画 3. 等等... 3. 一组工具函数 1. 装备武器 2. 取消装备武器 3. 开火 4. 等等.. 同时,在《重构》一书中,鼓励对具有相近语义的一组数据使用数据结构进行封装,因此Epic将我们前文提到的一些内容进行了有意义的单独抽象。例如: * 将武器自身的静态数据描述抽象为了一组单独的内容,即FWeaponData类,包含最大弹药量、单弹夹包含子弹数量等等不会随着游戏过程而改变的信息。 * 将武器在第一人称和第三人称模式下的不同动画,抽象为了一个FWeaponAnim的小数据结构 这就是前面UML图中FWeaponData、FWeaponAnim两个数据结构的来历。 由于这两个数据结构只是纯数值类,因此使用USTRUCT宏进行描述,虚幻的反射系统会自动创建该数据结构的描述性信息,从而允许对这些数据进行序列化和反序列化以存储和在网络同步,同时也支持编辑器中生成相应的编辑控件。 ### 两种不同的武器基类 游戏中出现了两种具有明显区别的武器: 1. 一种是开一枪立刻计算是否命中 2. 第二种是开一枪计算是否发射弹丸 故非常自然地,出现了名为AShooterWeapon_Instant和AShooterWeapon_Projectile两个类。 沿袭刚才的分析,Epic将静态的、不大会变化的数据,单独抽象为一个类,即FProjectileWeaponData和FInstantWeaponData。故此处只分析相对复杂的AShooterWeapon_Projectile。 如果看过官方那个打黄豆的案例,应该能够明白射弹(抛射物)是一个单独的Actor。 此处同理,AShooterWeapon_Projectile从自己持有的FProjectileWeaponData实例中获取对应的射弹类,然后生成对应实例。 前文提到,静态数据单独抽象的一大好处是配置和修改十分方便。在引擎中能够看到这样的界面: ![](https://box.kancloud.cn/b274f9bf66e76e528d11731cd7dc8651_411x425.png) 这里对应代码如下: ~~~ /** weapon config */ UPROPERTY(EditDefaultsOnly, Category=Config) FInstantWeaponData InstantConfig; ... /** weapon data */ UPROPERTY(EditDefaultsOnly, Category=Config) FWeaponData WeaponConfig; ~~~ 以此提供给数值设计人员一个更加方便、统一的设计位置。并且借助EditDefaultsOnly来有效限制编辑范围——只能编辑默认值,这也是防御性编程的一个良好范例。 此时再回顾开头描述的那张UML类图,是否能够理清这里的类关系了? ### 可拾取物 实质上可拾取物可以看作一个这样的Actor: 1. 数值 1. 重新刷新的时间 2. 行为 1. 被碰到后执行响应 3. 支持行为的表现数据 1. 重生特效、声音 2. 拾取特效、声音 Shooter Game同样设计了这样的逻辑。 ## 游戏逻辑与机制框架 Shooter Game的游戏逻辑与机制框架主要放置在Online文件夹,同时外部的零散文件中也包含了一部分内容。 ![](https://box.kancloud.cn/43a317b37baa045a38ccc534d59ecc32_625x905.png) 强烈建议读者阅读[InsideUE4-GamePlay架构](https://zhuanlan.zhihu.com/p/22813908)以清晰了解虚幻引擎部分的框架。此处只给出简单解释: * UGameEngine:引擎类 * UGameInstance:游戏单例类。为当前“游戏”的唯一代表,表示当前正在运行的游戏。 * 一个正在运行的游戏实例应当包含一个游戏世界,在进行世界切换时可以用过FWorldContext临时持有一个以上 * UWorld:游戏世界类。表示现今正处于的游戏世界。请注意,传统意义上的**切换地图**实质上是切换游戏世界,即: >玩家在切换PersistentLevel的时候,实际上就相当于切换了一个World。 from《Inside UE4》 * ULevel:世界一部分关卡的**数据表示**,当前关卡中的Actor数据会被保存至Level中。对应了虚幻引擎中的.umap文件。一个UWorld可以持有多个ULevel。 * AGameMode:当前关卡的**行为规则描述**,负责描述游戏的基本逻辑 * AGameState:当前游戏状态的**数据表示** Shooter Game在这个基础框架上,主要做出了以下扩展: ### 自己的GameInstance 语义上说,Game Instance的子类负责管理**独立于关卡**的逻辑。Epic给出的示范中,Shooter Game Instance通过重载Init、Shutdown、StartGameInstance三个函数,完成了对GameInstance的扩展。 扩展引擎的两个基本思路: 1. 继承基类,实现包含自己需要的逻辑的子类,然后向引用当前对象的其他类注册自己。 1. 一般来说其他类对该基类通过TSubclassOf宏进行引用,然后在需要的时候反射生成实例 2. 通过向特定的静态代理注册函数,从而实现扩展 Shooter Game Instance采用了第二个思路。在Init函数中向一系列代理进行了注册: ~~~ void UShooterGameInstance::Init() { Super::Init(); //...省略部分代码 FCoreDelegates::ApplicationWillDeactivateDelegate.AddUObject(this, &UShooterGameInstance::HandleAppWillDeactivate); FCoreDelegates::ApplicationWillEnterBackgroundDelegate.AddUObject(this, &UShooterGameInstance::HandleAppSuspend); FCoreDelegates::ApplicationHasEnteredForegroundDelegate.AddUObject(this, &UShooterGameInstance::HandleAppResume); FCoreDelegates::OnSafeFrameChangedEvent.AddUObject(this, &UShooterGameInstance::HandleSafeFrameChanged); FCoreDelegates::OnControllerConnectionChange.AddUObject(this, &UShooterGameInstance::HandleControllerConnectionChange); FCoreDelegates::ApplicationLicenseChange.AddUObject(this, &UShooterGameInstance::HandleAppLicenseUpdate); FCoreUObjectDelegates::PreLoadMap.AddUObject(this, &UShooterGameInstance::OnPreLoadMap); FCoreUObjectDelegates::PostLoadMap.AddUObject(this, &UShooterGameInstance::OnPostLoadMap); FCoreUObjectDelegates::PostDemoPlay.AddUObject(this, &UShooterGameInstance::OnPostDemoPlay); } ~~~ 扩展的内容包括: * 通过重载FCoreUObjectDelegates::PostLoadMap,在地图加载完毕后停止播放载入动画 * 通过重载UShooterGameInstance::Tick,在使用XBox在分屏模式下游玩时,检测是否存在控制器脱机的情况,如果存在则提示用户 通过这两个扩展案例,可以看出GameInstance扩展的类型:独立于具体游戏实例的逻辑。甚至可以发现,即使是在菜单地图中,这些逻辑依然是通用的。 故反向而言,如果有一部分游戏**功能**是完全独立于特定的逻辑的,则抽象至GameInstance层进行处理。 ### Game Mode Shooter Game创建了两个GameMode:AShooterGameMode和AShooterGameMode_TeamDeathMatch。 #### AShooterGameMode 作为基类的AShooterGameMode继承自AGameMode,提供基础性的功能,包括: 1. 开始游戏 2. 结束游戏 3. 玩家最佳出生点选择 4. Bot机器人创建 5. 游戏计时 6. ... 通过观察可以发现,AShooterGameMode和具体的地图相关。换句话说,AShooterGameMode实际上对应了**比赛地图**,而作为主菜单界面的地图,采用的是AShooterGame_Menu,继承的是AGameModeBase。 AGameMode和AGameModeBase的区别在于AGameMode包含联机的逻辑。因此作为单机菜单地图,只需要继承AGameModeBase就足够了。 此处可以印证,非全局性逻辑、与特定地图相关的逻辑,放置于GameMode中。 #### AShooterGameMode_TeamDeathMatch 作为范例,TeamDeathMatch加入了队伍选择和判定哪一队实现的功能。 ### Game State Game State 是游戏当前运行状态的表示,其主要持有的是**数据**信息和**状态**信息。 AShooterGameState 存储了当前的队伍数量、队伍分数、剩余时间等信息。 Game Mode与Game State互相持有对方的引用,其区别是,Game State是持久性的数据信息,Game Mode是逻辑。在Shooter Game中,Game Mode 通过 ~~~ AShooterGameState* const MyGameState = Cast<AShooterGameState>(GameState); ~~~ 以获得当前的游戏状态。其中GameState是AGameModeBase类的成员变量,引用的是当前游戏状态。 然后其操作GameState数据,以完成当前游戏状态的更新。 Game Mode实质上定义了一个很类似于CS的DeathMatch框架。其预定义了一系列的状态,位于MatchState命名空间中: ~~~ /** Possible state of the current match, where a match is all the gameplay that happens on a single map */ namespace MatchState { extern ENGINE_API const FName EnteringMap; // We are entering this map, actors are not yet ticking extern ENGINE_API const FName WaitingToStart; // Actors are ticking, but the match has not yet started extern ENGINE_API const FName InProgress; // Normal gameplay is occurring. Specific games will have their own state machine inside this state extern ENGINE_API const FName WaitingPostMatch; // Match has ended so we aren't accepting new players, but actors are still ticking extern ENGINE_API const FName LeavingMap; // We are transitioning out of the map to another location extern ENGINE_API const FName Aborted; // Match has failed due to network issues or other problems, cannot continue // If a game needs to add additional states, you may need to override HasMatchStarted and HasMatchEnded to deal with the new states // Do not add any states before WaitingToStart or after WaitingPostMatch } ~~~ 通过AGameMode::GetMatchState可以获取当前的比赛状态,通过AGameMode::SetMatchState可以设置当前的比赛状态。 注意官方的提醒:如果需要扩展自定义的比赛状态,需要重载HasMatchStarted和HasMatchEnded来处理新的状态。MatchState并非一个Enum,同时状态的转换也是通过设置一个FName实现,因此可以直接添加一个新的FName即可表示。只是需要自己在状态代码中做出处理。 需要提醒的是,Unreal Engine的这个Game Mode设定过于倾向于一个联机射击类游戏,同时这个机制也有UT3时代的影响,因此如果觉得这部分没有太大的必要,可以直接继承自Game Mode Base。 ## 用户界面与HUD 笔者并不趋向于讨论Shooter Game的界面系统。 由于Shooter Game实际上开发于4.0时代,那个时代是没有UMG系统的,所以个人认为参考意义并不是非常的大。 如果有朋友希望深入研究Slate界面系统,可以自行研究。 笔者推荐界面方案,根据需求规模,如果界面复杂度低则可以考虑更直观的UMG解决方案,界面复杂度较高则可以考虑自行封装库或者HTML5方案。 * * * * * # Loading Screen 曾经尝试自行实现载入画面的朋友一定知道,严格来说UMG是无法作为载入画面的。虽然有许多别的方案(创造一个中间关卡之类的),但是实际上并非专门的解决方案。 Shooter Game演示了一个解决方案,代码位于ShooterGameLoadingScreenModule中,在PreLoadingScreen时加载。其中除了模块定义类外,只包含了一个类:SShooterLoadingScreen2。 其中模块定义类通过重载函数,将自定义的Slate加载画面作为MoviePlayer,从而实现游戏加载和关卡切换时的载入画面: ~~~ class FShooterGameLoadingScreenModule : public IShooterGameLoadingScreenModule { public: virtual void StartupModule() override { // Load for cooker reference LoadObject<UObject>(NULL, TEXT("/Game/UI/Menu/LoadingScreen.LoadingScreen") ); if (IsMoviePlayerEnabled()) { FLoadingScreenAttributes LoadingScreen; LoadingScreen.bAutoCompleteWhenLoadingCompletes = true; LoadingScreen.MoviePaths.Add(TEXT("LoadingScreen")); GetMoviePlayer()->SetupLoadingScreen(LoadingScreen); } } virtual bool IsGameModule() const override { return true; } virtual void StartInGameLoadingScreen() override { FLoadingScreenAttributes LoadingScreen; LoadingScreen.bAutoCompleteWhenLoadingCompletes = true; LoadingScreen.WidgetLoadingScreen = SNew(SShooterLoadingScreen2); GetMoviePlayer()->SetupLoadingScreen(LoadingScreen); } }; ~~~ 这里可以看作是一个极简的载入画面的写法。且这个框架实际上比官方wiki中的另一个LoadingScreen插件更加简洁明了。有需要的读者可以参考这个模块。 请注意,**该模块的载入时机必须是PreLoadingScreen**