
1. 先把五个类的分工理清楚UE 为什么要把玩家相关的逻辑拆成这么多层刚开始接触 UE 的时候我也被这几个类绕晕过。一个玩家进了游戏为什么要分成 GameInstance、GameMode、GameState、PlayerState、PlayerController 五个类来承载直接一个类全干了不行吗后来在做一个四人联机合作项目时被网络同步问题按在地上摩擦了两周我才真正明白这套拆分逻辑的精妙之处。这五个类不是 UE 为了炫技硬造出来的概念而是被单机、局域网联机、专用服务器、多关卡切换这些真实场景逼出来的一整套职责划分方案。先把它们各自的身份用一句话定位GameInstance 是整个游戏进程的全局单例跨关卡存活GameMode 是一局游戏的规则裁判只存在于服务器GameState 是这局游戏的公开状态公告板所有端都有PlayerState 是单个玩家的档案卡跟着玩家走PlayerController 是玩家的操作手柄负责把输入翻译成意图。理解这五句话基本就理解了一半。这套体系面向的读者既包括刚学完官方教程、想搞明白我该把变量放哪个类的入门者也包括做过一两个 Demo、但一进多人模式就各种空指针和不同步的进阶开发者。我下面讲的每一条都是能直接落到项目里用的而不是照抄文档的概念罗列。尤其是那几个放错位置就会出问题的坑几乎每个 UE 项目都会踩一遍。1.1 从单机到联网这套架构到底在解决什么问题如果只做单机游戏说实话这五个类里你只需要 GameMode 和 PlayerController 就够了GameState 和 PlayerState 的存在感极低。但一旦你的项目要支持联机问题立刻就变了谁有权决定这局游戏什么时候结束玩家的血量变化谁来广播玩家掉线重连之后他的分数还记得吗关卡切换的时候之前设置好的音量、难度选项、存档数据怎么带过去UE 的答案是把这些不同性质的数据按生命周期和权威归属两个维度拆开。生命周期决定了它能不能跨越关卡权威归属决定了它是服务器说了算还是各客户端自己维护。GameMode 在一局结束时就被销毁了所以它适合放这局怎么打的规则GameInstance 伴随整个进程所以它适合放跨局甚至跨存档的全局数据。这套拆分不是为了好看是为了让你在写代码的时候天然就不会把服务器才该有的逻辑写到客户端会执行的类里。我见过太多新手项目把玩家分数直接存在 PlayerController 里。单机跑起来一点问题没有一联机就发现每个客户端看到的分数都不一样因为 PlayerController 是每个客户端各自持有的本地对象它的变量默认不会自动同步。这就是不按职责放数据的代价。1.2 生命周期对照谁活得最久谁一局就换把生命周期这张图记在脑子里很多决策会自动变得清晰。GameInstance 从游戏进程启动到退出全程只有一个实例关卡切换不会重建它——这是它最核心的价值。GameMode、GameState 属于World级别每加载一个新关卡World就会生成新的关卡一换就销毁重建。PlayerController 和 PlayerState 属于玩家级别只要这个玩家还连着就一直存在但换关卡时 UE 会尽量保活它们这点很关键后面讲重连时会展开。需要特别注意的是 PlayerState 和 PlayerController 在换关卡时的表现。早期版本里换关卡会重新生成 PlayerController后来 UE 改成尽量复用但如果你在 GameMode 里设置了bDelayedStart或者用了无缝切换Seamless Travel行为又不一样。我个人的做法是任何需要跨关卡保留的玩家数据一律放 PlayerState 或 GameInstance绝不放 PlayerController。这样不管 UE 内部怎么处理 Controller 的重建你的数据都是安全的。这里插一句实战心得判断一个变量该放哪就问自己两个问题——它需要跨关卡吗和它是所有人的共识还是我自己的本地状态。第一个问题回答 GameInstance vs 其他第二个问题回答 GameState/PlayerState共识需同步vs PlayerController本地不自动同步。1.3 一张表看清五者的定位与归属类名生命周期存在范围主要职责典型数据GameInstance进程级跨关卡所有端各一份全局单例、跨关卡数据、存档玩家账号、设置项、解锁进度GameMode单个 World仅服务器游戏规则、胜负判定、生成逻辑回合状态机、胜利条件GameState单个 World所有端同步广播当前对局公开信息剩余时间、两队比分、阶段PlayerState玩家连接期间所有端同步单个玩家的公开档案击杀数、昵称、队伍PlayerController玩家连接期间各端各一份输入处理、Possess、相机、HUD输入映射、视角目标这张表的每一列都是我在项目里反复验证过的。比如 GameMode 那一行仅服务器踩过坑的人才知道有多重要——你在客户端蓝图上给 GameMode 加逻辑某些情况下它会返回 null因为客户端压根没这个对象。2. GameInstance跨关卡存活的全局数据容器GameInstance 是我最喜欢也最容易被滥用的一个类。它的定位非常明确整个游戏进程里唯一存在、关卡切换不销毁的全局对象。正因为它是全局单例很多人把它当成了万能垃圾袋什么数据都往里塞结果项目一到后期就变成一个几千行的巨型类谁都不敢改。我在第二个项目里就犯过这个错最后花了一周重构把该进 GameInstance 的和不该进的彻底分开。先说清楚它到底适合放什么。跨关卡的数据是首选比如玩家在设置菜单里调好的画质、音量、难度进入游戏后要生效这些数据必须活过关卡切换再比如玩家的存档进度、解锁的成就、账号信息这些是进程级的放 GameInstance 天经地义。还有一类是全局服务性质的东西比如你的音频管理、存档管理、对象池管理器可以挂在 GameInstance 上让它跟着进程走。那什么不该放一局游戏内的临时状态比如当前关卡剩余多少敌人、这一波的刷怪计时这些放 GameInstance 就错了因为它们本该随关卡销毁你一换关卡它们还在反而会造成脏数据。另外任何需要网络同步的对局数据也不该放这里GameInstance 的变量默认不做属性同步你想同步还得自己写 RPC那还不如直接放 GameState。2.1 蓝图与 C 两种实现路径以及各自的坑多数人第一次接触 GameInstance 是在项目设置里指定一个蓝图类。路径是Project Settings → Maps Modes → Game Instance → Game Instance Class选一个继承自 GameInstance 的蓝图UE 会在游戏启动时自动实例化它。这个方式上手快适合快速验证但它有两个隐藏问题一是蓝图里的变量在编辑器里改不生效因为实例是运行时创建的二是蓝图读写复杂数据结构时性能一般如果你要在里面存几百条存档数据还是老实用 C。C 的路径是继承 UGameInstance然后在项目设置里指定你的 C 类或者蓝图继承自它。C 版的好处是可以在构造函数里初始化可以用FTableRowBase之类的高效结构还能暴露UFUNCTION给蓝图用。下面是我常用的一个基础骨架// MyGameInstance.h UCLASS() class MYGAME_API UMyGameInstance : public UGameInstance { GENERATED_BODY() public: virtual void Init() override; virtual void Shutdown() override; UPROPERTY(BlueprintReadWrite, Category Save) int32 PlayerLevel 1; UFUNCTION(BlueprintCallable, Category Save) void SaveAllData(); };// MyGameInstance.cpp void UMyGameInstance::Init() { Super::Init(); // 注册委托、加载配置等全局初始化逻辑写这里 UE_LOG(LogTemp, Log, TEXT(GameInstance Init)); }Init()是整个游戏最早被调用的一批函数之一适合在这里做全局注册Shutdown()在进程退出时调用是保存数据的最后机会。很多人在Init()里塞了大量耗时逻辑导致启动卡顿我的建议是重逻辑延后Init()只做轻量注册。2.2 从任意位置拿到 GameInstance 的正确姿势拿到 GameInstance 的引用是所有人都会遇到的问题。在 Actor 里可以用GetGameInstance()在 UObject 里可以用GetWorld()-GetGameInstance()在蓝图里直接用Get Game Instance节点再Cast成你的自定义类型。看起来简单但坑在于调用时机。如果在构造函数里调用GetWorld()可能为 null在BeginPlay之前的某些回调里调用World 可能还没完全初始化。我个人的封装习惯是写一个静态辅助函数把 cast 和判空都包进去用的时候一行搞定UMyGameInstance* UMyBlueprintFunctionLibrary::GetMyGI(const UObject* WorldContext) { if (!WorldContext) return nullptr; UWorld* World WorldContext-GetWorld(); if (!World) return nullptr; return CastUMyGameInstance(World-GetGameInstance()); }注意不要为了图省事把 GameInstance 的指针存成全局变量长期持有。GameInstance 虽然是单例但跨关卡时不保证所有内部引用都有效直接存指针容易在编辑器里 PIEPlay In Editor反复启动时拿到已被回收的对象。2.3 跨关卡传数据的实际做法做关卡切换时怎么把数据带走是我被问得最多的一个问题。答案其实很直接数据存 GameInstance切换关卡后重新从 GameInstance 读。具体做法是在关卡开始比如 GameMode 的BeginPlay或者玩家 Pawn 的BeginPlay时去 GameInstance 里取需要的值而不是试图把数据传过去。如果数据量不大直接读 GameInstance 的成员变量就够。如果数据结构复杂我会把数据序列化成USaveGame子类通过SaveGameToSlot/LoadGameFromSlot存读这样既能跨关卡也能跨进程。内嵌代码里我一般这么写// 保存 UMySaveGame* Save CastUMySaveGame(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())); Save-PlayerLevel GI-PlayerLevel; UGameplayStatics::SaveGameToSlot(Save, TEXT(Slot0), 0); // 读取 UMySaveGame* Loaded CastUMySaveGame(UGameplayStatics::LoadGameFromSlot(TEXT(Slot0), 0)); if (Loaded) { GI-PlayerLevel Loaded-PlayerLevel; }提示SaveGameToSlot是同步的大存档会卡帧。真要发布建议放到异步任务里或者用AsyncSaveGameToSlot。3. GameMode 与 GameState规则制定者与状态广播员GameMode 和 GameState 是一对必须一起理解的搭档。很多人第一次做联机把玩家进入游戏要加分这个逻辑写在了 GameMode 里然后发现客户端压根拿不到 GameMode一调用就崩。这时候才意识到GameMode 只存在于服务器端。这是 UE 网络架构里最硬的一条规则没有例外。理解这一点你就理解了 GameState 存在的必要性——因为客户端需要知道这局游戏现在是什么状态而状态又只有服务器能决定所以服务器把状态写进 GameStateGameState 通过网络复制同步给所有客户端。打个比方GameMode 是裁判裁判只在场地中央服务器走动GameState 是大屏幕计分板所有人都能看到裁判做出的判罚会立刻显示在计分板上。你作为球员客户端看不到裁判本人但能通过计分板知道当前比分和时间。这个比喻帮我理清了很多问题。比如我想在客户端显示距离回合结束还有 30 秒我不会去问 GameMode而是读 GameState 里被同步过来的剩余时间变量。再比如我想在客户端判断游戏是否已经结束也是读 GameState 里的阶段枚举而不是去调 GameMode 的函数。3.1 自定义 GameMode 与 GameState 的实操步骤在实际项目里我一般会先建两个 C 基类AMyGameMode和AMyGameState蓝图再继承它们这样逻辑放 C数值和表现放蓝图。步骤大致是这样新建AMyGameMode : public AGameModeBase在里面重写BeginPlay、PostLogin、Logout等关键回调。新建AMyGameState : public AGameStateBase把需要广播的变量用UPROPERTY(ReplicatedUsingOnRep_XXX)标记。在 GameMode 的构造函数里设置GameStateClass AMyGameState::StaticClass();把两者绑定。在项目设置或者关卡 World Settings 里指定 GameMode让这一局用你的规则。第 3 步是最容易漏的。很多人建了自定义 GameState却发现游戏里生成的还是默认的就是因为没在 GameMode 里指定GameStateClass。这个绑定关系必须显式建立UE 不会自动帮你猜。至于 GameState 的变量同步关键是重写GetLifetimeReplicatedPropsvoid AMyGameState::GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMyGameState, RemainingTime); DOREPLIFETIME(AMyGameState, TeamAScore); DOREPLIFETIME(AMyGameState, TeamBScore); }DOREPLIFETIME是告诉引擎这些变量需要同步到客户端。不加它你在服务器改多少遍客户端也不会变。3.2 GameState 在客户端读GameMode 在服务器写我想强调的是这套读写分离的习惯。凡是需要所有玩家看到的数据一律写在 GameState 里服务器负责改客户端只负责读。GameMode 里则放那些只有服务器需要知道的决策逻辑什么时候开始回合、什么时候判定胜负、什么时候生成道具。客户端完全不需要知道这些决策过程它只关心结果状态。举一个具体的例子。假设我要做一个限时击杀对手的模式。服务器端的 GameMode 会维护一个计时器时间到了就调用EndMatch()把 GameState 的阶段设为已结束然后把最终比分写进 GameState。客户端收到阶段变化后播放结算 UI。整个流程里客户端从来不调用 GameMode 的任何函数它只是被动接收 GameState 的变化然后做出反应。这就是正确的心智模型。如果你发现自己的代码在客户端需要访问 GameMode那一定是架构出了问题八成是某段逻辑本该放 GameState 却塞进了 GameMode。4. PlayerController 与 PlayerState玩家的手和档案把这组搭档和上一组对照着看会非常清晰。GameMode 对 GameState是规则对状态PlayerController 对 PlayerState则是操作对档案。PlayerController 是玩家的手负责抓取输入、控制视角、Possess 一个 PawnPlayerState 是这个玩家在这局里的档案记录他的昵称、得分、队伍归属。它们最本质的区别在于权威归属。PlayerController 在每个端都有一份实例客户端的那份负责处理本地输入服务器的那份负责验证和执行而 PlayerState 虽然也是每个端都有一份但它的数据以服务器为准通过复制同步给客户端。换句话说PlayerController 的变量默认是各管各的PlayerState 的变量默认是服务器说了算。这条区别是判断数据该放哪的金标准。举一个我踩过的具体坑。做排行榜的时候我一开始把击杀数存在 PlayerController 里服务器加一分客户端看自己的还是零。后来移到 PlayerState加DOREPLIFETIME所有端立刻同步了。原因就是 PlayerController 不会自动跨端同步玩家档案这种共识数据。4.1 PlayerController 的核心职责输入、相机、PossessPlayerController 干的三件事我在项目里几乎天天碰。第一是输入处理输入映射Input Mapping的解绑最终会走到 Controller 上通过SetupInputComponent()把按键绑定到回调函数。第二是相机控制PlayerCameraManager由 Controller 持有第一人称、第三人称的视角逻辑通常挂在 Controller 或者它 Possess 的 Pawn 上。第三是 Possess 关系Controller 通过Possess(Pawn)接管一个 Pawn 的控制UnPossess()则放开控制。关于 Possess有几个非常关键的行为需要记住。玩家死亡时通常的做法是UnPossess掉旧 Pawn 并销毁它然后进入观察者模式等复活后再 Possess 新 Pawn。这个过程里PlayerController 本身是保活的所以任何需要跨死亡保留的数据比如连杀数放在 Controller 里是安全的但放 Pawn 里就会随着 Pawn 销毁而丢失。这又是一个生命周期决定数据归属的实例。输入绑定的代码我一般这样写void AMyPlayerController::SetupInputComponent() { Super::SetupInputComponent(); InputComponent-BindAction(Jump, IE_Pressed, this, AMyPlayerController::OnJumpPressed); InputComponent-BindAxis(MoveForward, this, AMyPlayerController::MoveForward); } void AMyPlayerController::OnJumpPressed() { if (AMyCharacter* Char CastAMyCharacter(GetPawn())) { Char-Jump(); } }注意我用了GetPawn()再 cast而不是直接持有 Pawn 指针。因为 Pawn 可能在任意时刻被销毁或替换直接存指针很容易变成悬空指针。4.2 PlayerState 独立存在的原因复活、重连、数据归属很多人会问既然 PlayerController 已经跟着玩家走了为什么还要 PlayerState答案在几个特殊场景里会暴露得非常明显。第一个场景是死亡与复活。玩家死了Pawn 销毁Controller 还在但如果你把得分放 Controller 里逻辑上没错可一旦涉及跨队伍汇总就麻烦了——因为 Controller 是本地对象服务器汇总时得遍历所有 Controller而 Controller 的复制能力和 PlayerState 不一样PlayerState 天生就是为所有玩家都能看到每个玩家的信息设计的。第二个场景是掉线重连。玩家断线后Controller 会被销毁但 UE 的设计里 PlayerState 是可以跨无缝切换关卡保留的。如果你的数据放 PlayerState重连后还能恢复放 Controller重连后就没了。第三个场景是观战和旁观。观战者的 Controller 可能没有 Pawn但所有玩家的 PlayerState 依然存在所以观战 UI 能显示所有玩家的昵称和分数。基于这三点我把 PlayerState 当作玩家在这局里的公开名片来用昵称、头像 ID、队伍、击杀、死亡、助攻、延迟全放这里。这些数据的共同点是——它们需要被所有端看到且以服务器为准。4.3 Controller 与 Pawn 的 Possess 关系详解Possess 关系是理解这组类的一条隐藏线索。一个 PlayerController 同一时刻只能 Possess 一个 Pawn但一个 Pawn 可以被不同的 Controller 先后 Possess。这个一对多的关系决定了你不能把玩家身份绑定在 Pawn 上。我在做载具系统的时候深刻体会过这一点。玩家开车时 Controller Possess 载具下车时 UnPossess 载具、Possess 角色。整个过程里玩家还是那个玩家但 Pawn 换了两三次。如果我把玩家的等级、昵称存在 Pawn 里换一次就丢一次。存在 PlayerState 里全程稳定。这里有个细节要提醒Possess之后记得在 Pawn 里处理NotifyRestarted或者重写PossessedBy因为有些初始化逻辑比如相机设置、输入模式切换需要在 Possess 完成后再做。顺序搞错会导致输入失效或者相机抖动。5. 五者之间的调用关系与数据流向实战把五个类单独讲清楚之后最重要的是理解它们在一次完整游戏流程里是怎么串起来的。我按客户端加入服务器的时间线来梳理这条链路理清了你对整套架构的理解就成型了。游戏进程启动最先创建的是GameInstance它注册各类子系统、加载全局配置。然后玩家点击进入某个关卡UE 加载 World在服务器端创建GameMode和GameState并把 GameState 关联到 GameMode。玩家连接进来时服务器为他创建PlayerController和PlayerStateController 通过 Possess 接管一个 Pawn。GameMode 监听到玩家加入PostLogin更新 GameState 里的玩家列表。客户端这边收到复制过来的 GameState 和各个 PlayerState渲染出完整的对局画面。这条链路里每个类的创建时机和依赖关系都是固定的理解它就能预判某个类在某个时刻是否可用。5.1 从加入服务器到进入战斗的完整初始化链路我把它拆成服务器侧和客户端侧两条线来看会更清楚。服务器侧World 加载 → GameMode 构造 → GameState 构造 → 玩家连接 → PlayerController 构造 → PlayerState 构造 → Pawn 生成 → Possess → GameMode.PostLogin → 更新 GameState。客户端侧收到连接确认 → 本地 GameState 复制 → 各 PlayerState 复制 → 本地 PlayerController 生成 → 本地 Pawn 生成并可能被服务器授权 Possess → 输入开始生效。这里最容易出问题的是时序。比如你在 PlayerController 的BeginPlay里访问 PlayerState可能 PlayerState 还没复制过来拿到的是 null。稳妥的做法是在OnRep_PlayerState回调里处理依赖 PlayerState 的逻辑而不是在BeginPlay里抢跑。这个回调就是专门为PlayerState 准备好了这个时刻设计的。5.2 在任意位置拿到其他类引用的代码模板在项目里到处拿引用是常态我把常用的一组辅助函数整理下来几乎每个项目都直接抄。// PlayerController 里 AMyGameState* GS GetWorld()-GetGameStateAMyGameState(); AMyPlayerState* PS GetPlayerStateAMyPlayerState(); AMyGameMode* GM GetWorld()-GetAuthGameModeAMyGameMode(); // 仅服务器有效 // PlayerState / 任意 Actor 里 AMyGameState* GS GetWorld()-GetGameStateAMyGameState(); UMyGameInstance* GI GetWorld()-GetGameInstanceUMyGameInstance();注意GetAuthGameMode()在客户端返回 null这是设计如此不是 bug。要用 GameMode先确认当前是不是服务器可以用HasAuthority()或者GetNetMode() ! NM_Client判断。我特别想强调GetWorld()-GetAuthGameMode()这个函数名里的 Auth。它明确告诉你只有权威端服务器才拿得到。很多新手在客户端调用它得到 null 之后一顿排查其实完全没必要——换成 GameState 读数据就好了。5.3 数据放错位置的典型症状对照判断数据放哪除了前面说的两个问题我还总结了一套症状对照法。当你遇到下面这些症状时基本就是数据放错了地方症状大概率原因修正方向客户端读不到服务器的改动数据放在了非复制对象里移到 GameState / PlayerState 并加 DOREPLIFETIME换关卡后数据丢失数据放在了 World 级对象里移到 GameInstance 或 PlayerState死亡复活后数据清零数据放在了 Pawn 里移到 PlayerState 或 PlayerController多个客户端看到的值不一致数据放在了本地非权威对象移到有复制的 GameState / PlayerState客户端调用 GameMode 报 null在客户端访问了仅服务器对象改读 GameState这张表是我在真实项目里一条条攒出来的每次新人问我为什么我的分数不同步我基本都能靠它对上号。6. 高频踩坑与排查速查表理论讲完了这一节说点真正值钱的东西——那些文档里不会写、只有踩过才知道的坑。我做过三个联机项目光是在这几个类的选择上就浪费了不知道多少时间下面这些都是血泪换来的。第一个高频坑是在客户端访问 GameMode。前面提过但我还是要单独强调因为它太常见了。症状是客户端调用 GameMode 的某个函数直接崩溃或者拿到 null。排查方法很简单打印GetNetMode()如果是NM_Client就别碰 GameMode。替代方案是把需要的数据通过 GameState 暴露出来。第二个坑是忘了 DOREPLIFETIME。你明明把变量放对了 GameState服务器也改了客户端就是不变。九成是没在GetLifetimeReplicatedProps里注册。这个函数的签名很容易写错比如参数类型、const 限定写错了不报错但同步失效非常隐蔽。我的习惯是用引擎提供的DOREPLIFETIME宏别手写注册逻辑。第三个坑是在 GameState 的 getter 里做重量级计算。GameState 会被频繁访问如果你在它的属性 getter 里做遍历或者字符串拼接性能会肉眼可见地掉。正确的做法是把计算放在服务器改值的时刻GameState 只存结果。6.1 空指针与服务器端为 Null的排查思路空指针是这套架构里最常见的报错但它的原因往往不是对象真的不存在而是你访问的时机不对或你访问的端不对。我总结了一个排查顺序先确认端用GetNetMode()打印当前是服务器还是客户端。如果是客户端访问 GameMode直接放弃这个方向。再确认时机在BeginPlay里拿到 null很可能是对象还没复制完成。改用OnRep_XXX回调或者用IsValid()加延迟一帧的Timer。最后确认WorldGetWorld()本身在构造阶段可能为 null这是 UE 对象生命周期决定的构造函数里不碰需要 World 的调用是一劳永逸的习惯。6.2 问题速查表现象排查点常见解法客户端 GameMode 为空GetNetMode 是 NM_Client改读 GameStatePlayerState 在 BeginPlay 为空复制未完成改用 OnRep_PlayerState分数不同步缺 DOREPLIFETIME 或放错类移到 PlayerState 并注册换关卡数据丢数据在 World 级对象移到 GameInstance输入无效Possess 时序或输入组件未绑定检查 SetupInputComponent 与 PossessedBy复活后状态错乱数据在 Pawn移到 PlayerState / Controller编辑器反复 PIE 后引用失效缓存了跨 PIE 的指针每次用时重新获取6.3 我在实际项目里攒下的几条经验最后分享几条纯个人经验都是文档里不会写、但真的能省时间的。第一条新建一个项目时先把这五个类的自定义基类都建好哪怕暂时是空的。这样后面加数据时不会临时纠结放哪直接往对应的基类里加就行。这个习惯让我后面几个项目少返工很多次。第二条给 PlayerState 和 GameState 加复制属性时先从最简单的 int32 和 bool 开始。复杂结构比如 TArray 的复制有很多限制容易踩到结构体没标 UPROPERTY或者数组复制性能差的坑。等简单属性验证通了再往上加复杂度。第三条调试网络问题时永远在服务器和客户端各打一遍日志。我经常只看服务器日志觉得一切正常结果客户端那边压根没收到。后来养成习惯关键逻辑两边都打一眼就能看出是没发出去还是没收到。第四条别迷信最佳实践里所有变量都要标 Replicated。每次复制都有开销PlayerState 里那些纯本地用的临时变量标了复制反而浪费带宽。只复制真正需要所有端看到的东西剩下的留本地。第五条GameInstance 里尽量避免持有 Actor 引用。因为 Actor 是 World 级的跨关卡后会被销毁而 GameInstance 活得更久很容易造成悬空引用。要在 GameInstance 里存东西存数据int、float、FString、结构体别存对象指针。这套架构看起来五个类有点多但一旦你把每个类的职责和生命周期刻进脑子里写代码的时候会像有肌肉记忆一样变量该放哪几乎不用思考。我现在的项目里新人经常问我这个变量放哪我基本会反问一句它要跨关卡吗它是所有人的共识吗两个问题问完答案自己就出来了。这套判断逻辑是我这几年做联机项目最实用的一条经验希望对正在被这几个类绕晕的你有点帮助。