如果你做过UE4多人游戏,一定被这种问题折磨过:玩家的分数、昵称、皮肤这些数据,到底该写在哪个类里?写进Pawn,一死一重生全没了;写进GameMode,全服务器共享一份,张三的数据被李四覆盖。这个问题折腾了我很长一段时间,直到我真正想明白UE4里PlayerState(玩家状态)的定位——它其实就是游戏参与者的一张数据名片,跟着玩家本人走,不跟着角色走。
这篇文章我会从框架定位、网络复制、实际封装、常见坑位几个角度,把PlayerState彻底讲透。适合刚开始接触UE4多人游戏框架、或者已经在项目里被数据归属问题折磨过的开发者。看完全文,你应该能自己判断:哪些数据该放进PlayerState,哪些不该,以及写进去之后怎么保证它在服务器、客户端之间表现一致。
1. 为什么需要PlayerState:看似多余,却不可替代
1.1 一段被我拆掉重写的玩家数据结构
早年间我做过一个轻量对战Demo,团队里A同学很自然地想到把玩家数据挂在Character上:角色类里写Kills、Deaths、TeamID,当时跑起来确实也没毛病。可Demo稍微一复杂就乱了:
- 玩家死亡重生,Pawn被销毁重建,Kills跟着清零;
- 中途切换角色,旧角色上的数据直接消失;
- 杀敌数之类的数据明明比赛期间应该一直累计,却频繁"失忆"。
后来我把这一堆字段全部从Character拆出来,挪到PlayerState上,整个数据流立刻稳定了。这里想说的核心不是"Character不能存数据",而是:凡是描述"玩家本人"而不是"玩家当前角色"的数据,放Pawn里本质上就是放错了抽屉。 角色可以被击杀、被替换、被摧毁,但"玩家"这个抽象概念在整局游戏里是连续的。PlayerState这个类存在的最大意义,就是给"玩家本人"一个持续的、独立的存放点。
1.2 设计背后躲不开的三个诉求
为什么UE4要单独搞一个PlayerState,而不是让PlayerController或者GameMode把所有东西都包了?我自己拆下来,其实是三个绕不开的诉求在逼着它出现。
第一是持久性。Pawn可以重生重建,PlayerController虽然存活期长,但它的关注点是"输入、相机、UI入口"这些控制层面的东西。你硬把Kills塞进PlayerController,功能上能跑,逻辑上非常别扭——控制权切换座位、重连接管的时候,数据跟着控制权走一遍,迟早出事。PlayerState是独立于控制语义之外的"玩家档案",天然持久。
第二是可观察性。玩家状态不只是给自己看的。左上角排行榜要显示所有人的分数,击杀播报要让全员看见谁杀了谁,观战系统要读当前被观察者的段位。这些数据希望被服务器和所有客户端统一可见,不能只在某一份Controller里。PlayerState本身是网络Actor,默认向全体复制,天生适配这个场景。
第三是可迁移性。切地图、断线重连、从实况观战切换到选手视角,玩家对应的实体变了,但"玩家状态"不应被抹掉。PlayerState的生命周期与Controller深度绑定,却不随Pawn消失,也不在换图时重置,它跟着"玩家会话"走,就像名片跟着人走——换件衣服、换个座位,名片还是在口袋里。
拿身份证类比再合适不过:Pawn是你今天穿的衣服,Controller是你手里的方向盘,PlayerState才是那张写着"你是谁"的证件。衣服可以换,方向盘可以移交,证件上的信息在整局比赛里不会变。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PlayerState在UE4框架中的坐标:职责边界比想象中清晰
2.1 一张表理清五个类的关系
很多新人对Gameplay框架的困惑,不是不知道某个类的功能,而是不知道类跟类之间的边界。我直接把玩家相关类的职责整理成了一张表,项目里带人时也是先甩这张表:
| 类 | 谁创建/持有 | 生命周期 | 典型数据 | 网络可见性 |
|---|---|---|---|---|
| GameModeBase | 服务器专属 | 一局比赛期间 | 规则、流程、出生点逻辑 | 不复制,客户端无实例 |
| GameStateBase | 服务器创建,复制到所有客户端 | 一局比赛期间 | 局内倒计时、全局比分、比赛阶段 | 所有客户端可见 |
| PlayerController | 服务器与客户端各一个实例,互相映射 | 玩家登录到离开 | 输入、相机视角、UI入口 | 成对通信 |
| Pawn/Character | 服务器生成,客户端通过复制看见 | 每次生成/销毁 | 移动、外观、技能、碰撞 | 游戏世界中的实体 |
| PlayerState | 服务器为每个玩家创建 | 跨越重生与换图,整局跟随玩家会话 | 昵称、Kills、TeamID、自定义状态 | 默认复制给所有客户端 |
这张表里最容易混淆的是GameState和PlayerState。我遇到过不止一个人把两个类当成可以互换的容器。记住一句口诀:GameState描述的是"这一局怎么样",PlayerState描述的是"这个玩家怎么样"。 局内总比分、剩余人数、当前阶段,属于GameState;某个玩家的击倒数、连杀数、所选皮肤,属于PlayerState。一个是房间的公共告示牌,一个是每人手里的个人名片。
Controller和State的分工也值得说。Controller是"操作者",它管这个玩家能控制什么;PlayerState是"记录者",它管这个玩家是谁、表现得怎么样。控制权转移、观战切换,Controller可以换,State不动。反过来,玩家数据变更也不需要Controller参与。
2.2 生命周期细节:跨地图、重生、掉线不过掉数据
PlayerState的生命周期细看很有意思。首先是重生:玩家死亡、Pawn销毁,PlayerState毫发无损地留在原地,等新Pawn生成后继续提供服务。其次是切图:多人游戏切换关卡时,服务器会保留玩家的PlayerState并带到新关卡,玩家信息不会因为地图切换而清空。如果你们项目的多人模式切图后玩家分数归零,先别去查UI,回查PlayerState有没有被服务器主动清理。
还有一个容易忽略的场景:掉线。玩家断开连接后,服务器不会立刻销毁PlayerState,而是会保留一段时间用于断线重连。这段时间里GameState还能看到这个"半离线"玩家的数据。重连成功后,玩家会拿回原来的PlayerState,而不是重新建一个——这也是"名片还在人身上"的体现。项目中如果需要在掉线期间也展示旧数据,可以直接从PlayerState里读,不需要自己额外建离线缓存。
3. 网络复制是PlayerState的灵魂:属性复制与RPC的分工
3.1 谁创建它、谁拥有它、谁看见它
PlayerState的网络模型,是理解后续一切坑的基础。服务器在玩家登录时,为每个PlayerController创建对应的PlayerState,这个"创建权在服务器"的设定意味着客户端永远不能new一个PlayerState——只能等服务器通过连接把副本复制过来。
从可见性来看,PlayerState默认的复制规则是:对当前连接的客户端(Owning Connection)自然可见,对其他所有客户端也可见。这跟Pawn的"仅所属客户端+旁观者"复制策略不一样。毕竟玩家状态是公共信息,排行榜、结算面板、击杀播报都要用到。你可以这么理解:服务器负责"打印名片",然后把名片发给每个人一份,自己手里永远留着正本。
代码里访问玩家状态,最常见的是两个入口:
- 在PlayerController内:
GetPlayerState<AMyPlayerState>() - 在任意客户端想查"某玩家":先拿那个玩家的PlayerController,再取它的PlayerState;或者直接通过Actor的相应接口拿对应连接的状态。
稍微提醒一句:不要在客户端用PlayerController的数组强行索引PlayerState,顺序不代表玩家编号。真需要稳定的玩家标识,给PlayerState里放一个服务器分配的自增ID或者直接用Actor的FUniqueNetId体系,比用索引靠谱得多。
3.2 属性复制配置:DOREPLIFETIME家族
PlayerState作为网络Actor,绝大部分数据同步靠的是属性复制(Property Replication)。写法是所有UE4开发者都见过的三板斧:UPROPERTY加Replicated标记、重写GetLifetimeReplicatedProps、在cpp里注册。
我直接给一段精简但完整的例子:
cpp复制// MyPlayerState.h
UCLASS()
class MYGAME_API AMyPlayerState : public APlayerState
{
GENERATED_BODY()
public:
UPROPERTY(ReplicatedUsing = OnRep_Kills)
int32 Kills;
UPROPERTY(Replicated)
int32 TeamID;
UFUNCTION()
void OnRep_Kills();
void AddKill();
void SetTeamID(int32 NewTeamID);
protected:
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
// MyPlayerState.cpp
void AMyPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyPlayerState, Kills);
DOREPLIFETIME(AMyPlayerState, TeamID);
}
void AMyPlayerState::OnRep_Kills()
{
// 客户端收到Kills变化后触发,适合在这里更新UI
// 注意:服务器端不会自动执行OnRep,需要区分处理
}
void AMyPlayerState::AddKill()
{
if (HasAuthority())
{
Kills += 1;
}
}
void AMyPlayerState::SetTeamID(int32 NewTeamID)
{
if (HasAuthority())
{
TeamID = NewTeamID;
}
}
这里有几个值得反复强调的细节。第一,加HasAuthority()判断是为了防止客户端乱改数据,后面避坑部分会展开。第二,OnRep_Kills只在客户端上被触发,服务器不会自动执行,如果你希望服务器也有对应逻辑,要么在改数据时单独调用,要么借助其他通报机制。第三,如果你只想同步给某个条件满足的客户端,比如只同步给自己,可以用DOREPLIFETIME_CONDITION配合COND_OwnerOnly之类的条件,切忌一刀切全用普通复制。
当数据属性多起来之后,OnRep就不只是改一个数字了。一个常见的做法是:数据变化时,OnRep里广播一个蓝图委托,让所有绑定了该状态的Widget统一刷新。这样状态和UI之间是解耦的,UI不是主动去"要"数据,而是被数据变化"通知"过后再取。
3.3 什么时候该用RPC,什么时候该用属性复制
这是一个所有多人游戏开发者都会纠结的问题。我观察过很多项目,最常见的病是"能用RPC绝不复制"和"什么都复制,卡了也不知道为什么"。我的经验是:状态用复制,事件用RPC。
打个比方。击杀数属于状态:新加入的玩家需要马上看到当前的击杀数,晚到的观众连接也要立刻拿到补齐的数值。属性复制会把当前值完整地同步过来,天然支持"加入同步"。而"某某打出了三杀"属于事件:它是一次性、瞬时的通报。用RPC推过去,客户端播放音效、飘字、播报,播完就结束,不需要记住这个事件本身。
选RPC或复制时,拿这两条规则去套:
- 需要"只要我加入就要知道当前值"的数据,走属性复制。
- 需要"那一刻那一瞬间大家看到同一个效果"的数据,走事件,也就是Multicast RPC。
- 高频率变化且低重要性甚至不需要全网同步的数据,比如某个特效参量,不要放PlayerState复制,留在Pawn本地或Server直接管理。
另外要注意,RPC本身的可靠与不可靠之分不是随意选的。玩家改名这种必须到达的消息用Reliable;击杀播报这种丢了也就丢了、下次还能补个击杀数的,可以用Unreliable,但最好由某个可靠的状态变更兜底。纯RPC没有"连接后补齐"的能力,这是它和属性复制最大的差异。
4. 从零封装一个真实可用的自定义PlayerState
4.1 定义数据字段与业务边界
理论说完了,直接进入实践。假设我们要做一个轻量对战Demo"模拟项目X",PlayerState里需要这么一组信息:队伍编号、击杀数、死亡数、当前选用的皮肤索引、玩家是否准备完毕。这些字段有一个共同点——都是"这个玩家在这次对局中的公共属性"。任何其他玩家都可以查看,服务器需要权威记录。
那"鼠标灵敏度""音量设置"这类只对本人有意义且有本地存档需求的配置放哪?我会放在PlayerController或者PlayerSaveGame里,不放进PlayerState。判断标准很简单:别的客户端如果永远不需要知道这个数据,它就不该被复制。 网络带宽很贵,PlayerState不是垃圾桶,别把什么字段都往里堆。
4.2 完整的类骨架与网络注册代码
下面是一份更完整的自定义PlayerState,我会标注清楚哪些字段走复制、哪些走回调、哪些只在服务器处理。
cpp复制// SProjectPlayerState.h
UCLASS()
class MYGAME_API ASProjectPlayerState : public APlayerState
{
GENERATED_BODY()
public:
// 复制且触发回调:击杀数
UPROPERTY(ReplicatedUsing = OnRep_Scores)
int32 Kills;
// 复制但不触发回调:队伍ID,多数情况下只需要值,不需要专门事件
UPROPERTY(Replicated)
uint8 TeamID;
// 复制且触发回调:准备状态,可能影响UI按钮和开局判断
UPROPERTY(ReplicatedUsing = OnRep_ReadyState)
uint8 bIsReady : 1;
// 本地字段,不复制:仅供该客户端本地调试使用
UPROPERTY()
FString LocalDebugTag;
UFUNCTION()
void OnRep_Scores();
UFUNCTION()
void OnRep_ReadyState();
UFUNCTION(BlueprintCallable, Server, Reliable, WithValidation)
void ServerSetReady(bool bNewReady);
bool ServerSetReady_Validate(bool bNewReady);
void ServerSetReady_Implementation(bool bNewReady);
void AddKill(int32 Count);
void InitTeamID(uint8 NewTeamID);
protected:
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
// SProjectPlayerState.cpp
void ASProjectPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ASProjectPlayerState, Kills);
DOREPLIFETIME(ASProjectPlayerState, TeamID);
DOREPLIFETIME(ASProjectPlayerState, bIsReady);
}
void ASProjectPlayerState::OnRep_Scores()
{
OnScoresChanged.Broadcast(Kills);
}
void ASProjectPlayerState::OnRep_ReadyState()
{
OnReadyStateChanged.Broadcast(!!bIsReady);
}
void ASProjectPlayerState::AddKill(int32 Count)
{
if (HasAuthority())
{
Kills += Count;
}
}
void ASProjectPlayerState::InitTeamID(uint8 NewTeamID)
{
if (HasAuthority())
{
TeamID = NewTeamID;
}
}
bool ASProjectPlayerState::ServerSetReady_Validate(bool bNewReady)
{
return true;
}
void ASProjectPlayerState::ServerSetReady_Implementation(bool bNewReady)
{
bIsReady = bNewReady;
}
注意ServerSetReady用了Server RPC加Validate校验:客户端告诉服务器"我把准备状态改了",服务器确认后才会真正修改复制的bIsReady。这样能避免客户端直接改属性的权限问题。RPC的命名规则是Server前缀+Implementation/Validate配套,漏了任何一个编译器都会报错。
4.3 在GameMode中配置与初始化的正确时机
PlayerState的赋值经常发生在GameMode的登录流程里。服务器端从PostLogin开始就有完整的PlayerController和PlayerState了,这是做初始化最稳的时机。
cpp复制void AMyGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
ASProjectPlayerState* PS = NewPlayer->GetPlayerState<ASProjectPlayerState>();
if (PS)
{
PS->InitTeamID(AssignTeamForPlayer(NewPlayer));
// 其他服务器权威的初始化也可以放在这里
}
}
有一个初学者容易踩的点:不要在PlayerState的构造函数里做依赖网络或依赖GameMode的初始化。构造函数执行时机非常早,可能早于PlayerController绑定、早于GameMode设置完成,甚至在某些客户端流程里构造函数和属性复制顺序不稳定。正确的做法是:延迟到PostLogin、或者HandleStartingNewPlayer阶段再初始化。
另外一个容易踩的点:如果项目有"中途加入"机制,晚到的玩家在PostLogin时已经是比赛中期,需要把当前局内状态(GameState里的总分、阶段、当前地图所有玩家的列表)一次性同步给新玩家,这时候就需要PlayerState配合GameState做一次"信息补齐",而不仅仅是初始化自己的字段。这一步做不好,后加入的玩家会看到一片空的排行榜。
5. 项目实战里回报率最高的避坑清单
5.1 数据放错类:从"Pawn里存分数"到"查了三天才找到的丢数据Bug"
这个案例来自我经手过的一个项目。某系统为了图方便,把玩家的当前连杀数挂在Character上,还专门做了UI绑定,单机测试一切正常。联机之后问题暴露:角色死亡重生,连杀显示清零;更诡异的是,有时候击杀提示明明还在飘,排行榜数据却是旧的。
排查链路是这样的:先怀疑UI刷新逻辑,发现Widget确实在等待数据更新事件,但事件源绑在旧Pawn上,Pawn销毁后事件源跟着销毁——UI自然拿不到后续变化。继续追,又发现团队里另一个模块读的是PlayerController上缓存的连杀数,而这个缓存只在Pawn生成时写过一次。数据存放点不统一,一个状态被Center到三个类,任何一边更新都会造成不一致。
最后把所有跨重生保留的数据统一归拢到PlayerState,Pawn只保留移动、技能闪避等与"当前化身"强相关的字段,问题彻底消失。排查过程本身不复杂,但数据放错类导致的"看起来很多余,找起来很崩溃",值得每个团队引以为戒。
5.2 客户端改了复制属性,立刻回滚:权限边界
有段时间我们的测试反馈一个诡异问题:客户端本地加击杀数,UI闪了一下正确数字,随即又被回滚到旧值。这个现象就是典型的"客户端直接修改复制属性"。
复制属性的规则是服务器为准。客户端虽然能改自己本地那份副本,但服务器不会采纳,更糟的是新值不会通知服务器,导致下一次复制同步时客户端被强制覆盖回服务器的版本。所以项目里所有修改PlayerState复制字段的入口,都必须走服务器权威路径。要么是GameMode/服务器函数直接改,要么是客户端通过Server RPC请求修改。判断标准永远是那句HasAuthority()。
我也见过一种折中方案:客户端先"乐观更新"本地UI(不等服务器确认),服务器处理完再通过属性复制或RPC回调修正。这在射击游戏击杀反馈这类低风险场景里能用,但前提是你必须接受"可能短暂闪回"的风险。如果项目对分数一致性要求极高,别做乐观更新,老老实实等服务器确认成功后再刷新UI。
5.3 OnRep与RPC的选择:为什么总是"服务器传状态,客户端发事件"
前面说过"状态用复制,事件用RPC",但实际项目里还是经常翻车。一个典型场景:服务器每产生一次击杀,就发一个Multicast RPC给所有人播放播报,同时复制一份击杀数给UI。结果客户端重复触发了两次UI刷新,一次来自RPC,一次来自OnRep回调。
我自己习惯的分工是:RPC负责"一次性表现",比如音效、飘字、镜头震动;PlayerState的OnRep负责"状态收敛",比如复活后UI读取最新击杀数。两者职责错开,不要在同一路径上同时驱动同一个UI逻辑。如果实在需要玩家死亡信息事件又需要击杀数状态,宁可把播报的文案参数放进RPC,让UI只响应RPC;击杀数变化只让排行榜响应OnRep。各管各的,才不会互相打架。
另外,OnRep在服务器上不会执行,如果服务器也需要在数据变化时做点什么(比如判断比赛是否结束),需要额外在修改处调用同一段逻辑。很多人第一次写OnRep时被这个"客户端专用回调"思路坑到。
5.4 数组与结构体的复制陷阱
PlayerState里除了基本数字,难免有数组或结构体。这一块最容易踩的两个问题是:TArray复制有脏标记陷阱,结构体复制有更新粒度陷阱。
对于TArray,修改成员后必须显式标记MarkPropertyDirty(),否则服务器可能不认为数组变更了。这个问题在"数组里的对象属性变了但数组长度没变"时特别隐蔽。更难受的是高密度数据结构,每次数组整体复制都是全量重发,带宽占用极高。
对于结构体,默认是按整个结构体同步的,一旦结构体内部某个字段变了,整个结构体都要重发。字段多、包体大、外加高频率更新,会直接拖垮网络表现。我见过一个项目把玩家所有装备数据塞进一个巨型结构体复制到PlayerState,每捡一个道具就全量传一次,客户端掉线率肉眼可见地上升。
合理的处理方式:PlayerState里只放"汇总型"状态,比如总分、总击杀、当前穿戴的外显索引;真正的物品列表、背包字典这类大结构放GameState或专门的Actor做细分复制,必要时用RPC增量通知。别犯懒把整张表都复制进PlayerState。
5.5 掉线、重连与观战模式下PlayerState的确认
掉线重连这块,见过太多团队在PlayerState上栽跟头。默认情况下,服务器会保留掉线玩家的PlayerState,重连后重新关联同一个PlayerState实例。但如果你在掉线流程里亲手销毁了PlayerState,或者重连时手洗了PlayerState,那"重连保留数据"的效果就全没了。我的建议是:在GameMode里明确一套"掉线后PlayerState是否保留、保留多久、重连如何找回"的策略,把它写进项目的网络设计文档,不要等到出Bug后再猜。
观战模式也有一个隐蔽问题:观战者本身可能没有传统意义上的PlayerState归属,而某些版本里观战视角切换时GetPlayerState()可能返回空。UI层如果每次都直接GetPlayerState()->GetScore()而不判空,观战时很容易出现空引用崩溃。所有读玩家状态的函数,进函数先检查返回的PlayerState是否有效,多写一行if (PS == nullptr) return;,能省下大量观战时的崩溃报告。
6. UI数据绑定:让Widget等数据,而不是数据追Widget
6.1 轮询是笨办法,事件驱动才是正路
把PlayerState的数据显示到UI上,最直白的写法是Widget的Tick函数里每帧取一次GetPlayerState()->GetKills()。我早期项目就这么干过,跑起来能看,但CPU白白烧掉不少,而且状态经常滞后一帧。
事件驱动才是常规项目该有的姿势:PlayerState的数据变化时主动广播委托,Widget只在自己关心的数据变化时才去取值刷新。牺牲一点绑定代码量,换来的是更低的开销和更及时的刷新。
6.2 在PlayerState里定义变更通知委托
沿用前面的类骨架,在头文件里加委托声明:
cpp复制DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnScoresChanged, int32, NewScore);
UCLASS()
class MYGAME_API ASProjectPlayerState : public APlayerState
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintAssignable, Category = "PlayerState")
FOnScoresChanged OnScoresChanged;
};
然后在OnRep_Scores里Broadcast:
cpp复制void ASProjectPlayerState::OnRep_Scores()
{
OnScoresChanged.Broadcast(Kills);
}
Widget在NativeConstruct时绑定,NativeDestruct时解绑:
cpp复制void UMyScoreWidget::NativeConstruct()
{
Super::NativeConstruct();
ASProjectPlayerState* PS = GetOwningPlayerState();
if (PS)
{
PS->OnScoresChanged.AddDynamic(this, &UMyScoreWidget::HandleScoreChanged);
// 绑定后立刻同步一次当前值,避免等下一次变化
HandleScoreChanged(PS->Kills);
}
}
void UMyScoreWidget::NativeDestruct()
{
if (ASProjectPlayerState* PS = GetOwningPlayerState())
{
PS->OnScoresChanged.RemoveDynamic(this, &UMyScoreWidget::HandleScoreChanged);
}
Super::NativeDestruct();
}
这里面有个小技巧:绑定完委托后,立即手动调用一次处理函数拉取当前值。因为属性复制可能早于Widget创建完成,如果不主动读取,Widget要一直等到下一次击杀变化才会显示正确数值,那体验相当糟糕。
6.3 延迟初始化问题:Widget先于PlayerState拿到实例
多人游戏里有一个典型的时序问题:客户端Pawn已经生成、Widget已经创建,但PlayerState副本可能还没复制到本地,或者尚未完成OnRep流程。这时候GetOwningPlayerState()拿到空指针,直接崩或者显示空数据。
应对方式我总结过三个层次。第一个层次是判空兜底,UI在取不到PlayerState时显示占位文案;第二个层次是延迟重试,用GetWorld()->GetTimerManager()做一帧或零点几秒后的重试;第三个层次是设计同步信号——在PlayerState完成本地初始化之后广播一个"状态就绪"事件,所有依赖PlayerState的Widget监听这个事件再开始取数。第三个层次最优雅,但需要全局约定好一个就绪事件名,别让每个Widget各写各的。
还有一点:如果用的引擎版本支持UE5的MVVM框架,可以更彻底地把数据层和表现层解耦,直接在ViewModel里订阅PlayerState的委托。原理和上面一样,只是封装程度更高。
文章最后分享一个我自己的小习惯:给每个自定义PlayerState加一个只在本地生效的调试字段,比如LocalDebugTag,排查线上问题时一眼定位是哪个连接的数据,省了很多事。对刚接触框架的同学,判断数据该不该放进PlayerState,就拿三个问题问自己:它是不是属于玩家个体?它要不要跨角色重生保留?其他客户端要不要看到它?三个都是"是",才放进来;只对自己有用的,留在PlayerController或者本地存档里更合适。想清楚这几件事,多人游戏的数据归属基本就不会再乱了。
