引言
回顾上文,我们谈完了World和Level级别的逻辑操纵控制,如同分离组合的AController一样,UE在World的层次上也采用了一个分离的AGameMode来抽离了游戏关卡逻辑,从而支持了逻辑的组合。本篇我们继续上升一个层次,考虑在World之上,游戏还需要哪些逻辑控制?
暂时不考虑别的功能系统(如社交系统,统计等各种),单从游戏性来讨论,现在闭上眼睛,想象我们已经藉着UE的伟力搭建了好了一个个LevelWorld,嗯,就像《西部世界》一样,场景已经搭建好了,世界规则故事也编写完善,现在需要干些什么?当然是开始派玩家进去玩啦!
大家都是老玩家了,想想我们之前玩的游戏类型:
- 玩家数目是单人还是多人
- 网络环境是只本地还是联网
- 窗口显示模式是单屏还是分屏
- 输入模式是共用设备还是分开控制(比如各有手柄)
- 也许还有别的不同
假如你是个开发游戏引擎的,会怎么支持这些不同的模式?以笔者见识过的大部分游戏引擎,解决这个问题的思路就是不解决,要嘛是限制功能,要嘛就是美名其曰让开发者自己灵活控制。不过想了一下,这也不能怪他们,毕竟很少有引擎能像UE这样历史悠久同时又能得到足够多的游戏磨练,才会有功夫在GamePlay框架上雕琢。大部分引擎还是更关注于实现各种绚丽的功能,至于怎么在上面开展游戏逻辑,那就是开发者自己的事了。一个引擎的功能是否强大,是基础比拼指标;而GamePlay框架作为最高层直面用户的对接接口,是一个引擎的脸面。所以有兴趣游戏引擎研究的朋友们,区分一个引擎是否“优秀”,第二个指标是看它是否设计了一个优雅的游戏逻辑编写框架,一般只有基础功能已经做得差不多了的引擎开发者才会有精力去开发GamePlay框架,因为游戏引擎不止渲染!
言归正传,按照软件工程的理念,没有什么问题是不能通过加一个间接层解决的,不行就加两层!所以既然我们在处理玩家模式的问题,理所当然的是加个间接层,将玩家这个概念抽象出来。
那么什么是玩家呢?狭义的讲,玩家就是真实的你,和你身旁的小伙伴。广义来说,按照图灵测试理论,如果你无法分辨另一方是AI还是人,那他其实就跟玩家毫无区别,所以并不妨碍我们将网络另一端的一条狗当作玩家。那么在游戏引擎看来,玩家就是输入的发起者。游戏说白了,也只是接受输入产生输出的一个程序。所以有多少输入,这些输入归多少组,就有多少个玩家。这里的输入不止包括本地键盘手柄等输入设备的按键,也包括网线里传过来的信号,是广义的该游戏能接受到的外界输入。注意输出并不是玩家的必要属性,一个玩家并不一定需要游戏的输出,想象你闭上眼睛玩马里奥或者有个网络连接不断发送来控制信号但是从来不接收反馈,虽然看起来意义不大,但也确实不能说这就不是游戏。
在UE的眼里,玩家也是如此广义的一个概念。本地的玩家是玩家,网络联机时虽然看不见对方,但是对方的网络连接也可以看作是个玩家。当然的,本地玩家和网络玩家毕竟还是差别很大,所以UE里也对二者进行了区分,才好更好的管理和应用到不同场景中去,比如网络玩家就跟本地设备的输入没多大关系了嘛。
UPlayer
让我们假装自己是UE,开始编写Player类吧。为了利用上UObject的那些现有特性,所以肯定是得从UObject继承了。那能否是AActor呢?Actor是必须在World中才能存在的,而Player却是比World更高一级的对象。玩游戏的过程中,LevelWorld在不停的切换,但是玩家的模式却是脱离不变的。另外,Player也不需要被摆放在Level中,也不需要各种Component组装,所以从AActor继承并不合适。那还是保持简单吧:
如图可见,Player和一个PlayerController关联起来,因此UE引擎就可以把输入和PlayerController关联起来,这也符合了前文说过的PlayerController接受玩家输入的描述。因为不管是本地玩家还是远程玩家,都是需要控制一个玩家Pawn的,所以自然也就需要为每个玩家分配一个PlayerController,所以把PlayerController放在UPlayer基类里是合理的。
ULocalPlayer
然后是本地玩家,从Player中派生下来LocalPlayer类。对本地环境中,一个本地玩家关联着输入,也一般需要关联着输出(无输出的玩家毕竟还是非常少见)。玩家对象的上层就是引擎了,所以会在GameInstance里保存有LocalPlayer列表。
UE4里的ULocalPlayer也如图所见,ULocalPlayer比UPlayer多了Viewport相关的配置(Viewport相关的内容在渲染章节讲述),也终于用SpawnPlayerActor实现了创建出PlayerController的功能。GameInstance里有LocalPlayers的信息之后,就可以方便的遍历访问,来实现跟本地玩家相关操作。
关于游戏的详细加载流程目前不多讲述(按惯例在相应引擎流程章节讲述),现在简单了解一下LocalPlayer是怎么在游戏的引擎的各个环节发挥作用的。UE在初始化GameInstance的时候,会先默认创建出一个GameViewportClient,然后在内部再转发到GameInstance的CreateLocalPlayer:
ULocalPlayer* UGameInstance::CreateLocalPlayer(int32 ControllerId, FString& OutError, bool bSpawnActor)
{
ULocalPlayer* NewPlayer = NULL;
int32 InsertIndex = INDEX_NONE;
const int32 MaxSplitscreenPlayers = (GetGameViewportClient() != NULL) ? GetGameViewportClient()->MaxSplitscreenPlayers : 1;
//已略去错误验证代码,MaxSplitscreenPlayers默认为4
NewPlayer = NewObject<ULocalPlayer>(GetEngine(), GetEngine()->LocalPlayerClass);
InsertIndex = AddLocalPlayer(NewPlayer, ControllerId);
if (bSpawnActor && InsertIndex != INDEX_NONE && GetWorld() != NULL)
{
if (GetWorld()->GetNetMode() != NM_Client)
{
// server; spawn a new PlayerController immediately
if (!NewPlayer->SpawnPlayActor("", OutError, GetWorld()))
{
RemoveLocalPlayer(NewPlayer);
NewPlayer = NULL;
}
}
else
{
// client; ask the server to let the new player join
NewPlayer->SendSplitJoin();
}
}
return NewPlayer;
}
可以看到,如果是在Server模式,会直接创建出ULocalPlayer,然后创建出相应的PlayerController。而如果是Client(比如Play的时候选择NumberPlayer=2,则有一个为Client),则会先发送JoinSplit消息到服务器,在载入服务器上的Map之后,再为LocalPlayer创建出PlayerController。
而在每个PlayerController创建的过程中,在其内部会调用InitPlayerState:
void AController::InitPlayerState()
{
if ( GetNetMode() != NM_Client )
{
UWorld* const World = GetWorld();
const AGameModeBase* GameMode = World ? World->GetAuthGameMode() : NULL;
//已省略其他验证和无关部分
if (GameMode != NULL)
{
FActorSpawnParameters SpawnInfo;
SpawnInfo.Owner = this;
SpawnInfo.Instigator = Instigator;
SpawnInfo.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnInfo.ObjectFlags |= RF_Transient; // We never want player states to save into a map
PlayerState = World->SpawnActor<APlayerState>(GameMode->PlayerStateClass, SpawnInfo );
// force a default player name if necessary
if (PlayerState && PlayerState->PlayerName.IsEmpty())
{
// don't call SetPlayerName() as that will broadcast entry messages but the GameMode hasn't had a chance
// to potentially apply a player/bot name yet
PlayerState->PlayerName = GameMode->DefaultPlayerName.ToString();
}
}
}
}
这样LocalPlayer最终就和PlayerState对应了起来。而网络联机时其他玩家的PlayerState是通过Replicated过来的。
我们谈了那么久的玩家就是输入,体现在在每个PlayerController接受Player的时候:
void APlayerController::SetPlayer( UPlayer* InPlayer )
{
//[...]
// Set the viewport.
Player = InPlayer;
InPlayer->PlayerController = this;
// initializations only for local players
ULocalPlayer *LP = Cast<ULocalPlayer>(InPlayer);
if (LP != NULL)
{
// Clients need this marked as local (server already knew at construction time)
SetAsLocalPlayerController();
LP->InitOnlineSession();
InitInputSystem();
}
else
{
NetConnection = Cast<UNetConnection>(InPlayer);
if (NetConnection)
{
NetConnection->OwningActor = this;
}
}
UpdateStateInputComponents();
// notify script that we've been assigned a valid player
ReceivedPlayer();
}
可见,对于ULocalPlayer,APlayerController内部会开始InitInputSystem(),接着会创建相应的UPlayerInput,BuildInputStack等初始化出和Input相关的组件对象。现在先明白到LocalPlayer才是PlayerController产生的源头,也因此才有了Input就够了,特定的Input事件流程分析在后续章节再细述。
思考:为何不在LocalPlayer里编写逻辑?
作为游戏开发者,相信大家都有这么个体会,往往在游戏逻辑代码中总会有一个自己的Player类,里面放着这个玩家的相关数据和逻辑业务。可是在UE里为何就不见了这么个结构?也没见UE在文档里有描述推荐你怎么创建自己的Player。
这个可能有两个原因,一是UE从FPS-Specify游戏起家,不像现在的各种手游有非常重的玩家系统,在UE的眼中,Level和World才是最应该关注的对象,因此UE的视角就在于怎么在Level中处理好Player的逻辑,而非在World之外的额外操作。二是因为在一个World中,上文提到其实已经有了Pawn-PlayerController和PlayerState的组合了,表示、逻辑和数据都齐备了,也就没必要再在Level掺和进Player什么事了。当然你也可以理解为PlayerController就是Player在Level中的话事人。
凡事留一线,日后好相见。尽管如此,UE还是给了我们自定义ULocalPlayer子类的机会:
//class UEngine:
/** The class to use for local players. */
UPROPERTY()
TSubclassOf<class ULocalPlayer> LocalPlayerClass;
/** @todo document */
UPROPERTY(globalconfig, noclear, EditAnywhere, Category=DefaultClasses, meta=(MetaClass="LocalPlayer", DisplayName="Local Player Class"))
FStringClassReference LocalPlayerClassName;
你可以在配置中写上LocalPlayer的子类名称,让UE为你生成你的子类。然后再在里面写上一些特定玩家的数据和逻辑也未尝不可,不过这部分额外扩展的功能就得用C++来实现了。
UNetConnection
非常耐人寻味的是,在UE里,一个网络连接也是个Player:
包含Socket的IpConnection也是玩家,甚至对于一些平台的特定实现如OculusNet的连接也可以当作玩家,因为对于玩家,只要能提供输入信号,就可以当作一个玩家。
追根溯源,UNetConnection的列表保存在UNetDriver,再到FWorldContext,最后也依然是UGameInstance,所以和LocalPlayer的列表一样,是在World上层的对象。
本篇先前瞻一下结构,对于网络部分不再细述。
总结
本篇我们抽象出了Player的概念,并依据使用场景派生出了LocalPlayer和NetConnection这两个子类,从此Player就不再是一个虚无缥缈的概念,而是UE里的逻辑实体。UE可以根据生成的Player对象的数量和类型的不同,在此上实现出不同的玩家控制模式,LocalPlayer作为源头Spawn出PlayerController继而PlayerState就是实证之一。而在网络联机时,把一个网络连接看作是一个玩家这个概念,把在World之上的输入实体用Player统一了起来,从而可以实现出灵活的本地远程不同玩家模式策略。
尽管如此,UPlayer却像是深藏在UE里的幕后功臣,UE也并不推荐直接在Player里编程,而是利用Player作为源头,来产生构建一系列相关的机制。但对于我们游戏开发者而言,知道并了解UE里的Player的概念,是把现实生活同游戏世界串联起来的很重要的纽带。我们在一个个World里向上仰望,还能清楚的看见一个个LocalPlayer或NetConnection仿佛在注视着这片大地,是他们为World注入了生机。
已经到头了?并没有,我们继续向上逆风飞翔,终将得见游戏里的神:GameInstance。