目录

Lyra - 网络同步

Lyra 的网络同步不是简单地给变量加 Replicated。它的核心设计是:先把数据放到正确的对象上,然后再决定用属性复制、RPC、GAS、FastArray、SubObject 或 RepGraph 去同步。

所以学习 Lyra 网络同步时,不要一开始就陷入各种宏,而是先问四个问题:

  • 这个数据谁有权修改?
  • 这个数据应该放在 PlayerState、Pawn、Controller、GameState,还是某个 Component 上?
  • 哪些客户端需要看到这个数据?
  • 客户端收到以后,是要保存状态,还是触发表现?

Lyra 的整体同步关系可以先理解成这样:

1
2
3
4
5
6
7
Client 输入 / 预测表现
Server 权威校验 / 修改真实状态
Replication / RPC / GAS / FastArray
Client OnRep / GameplayMessage / GameplayCue / UI 表现

Lyra 同步对象怎么分层

Lyra 没有把所有状态都塞到 Character 上,而是按照生命周期拆开了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
GameState
  - 当前 Experience
  - 全局 AbilitySystemComponent
  - 全局消息

PlayerState
  - 玩家长期状态
  - AbilitySystemComponent
  - PawnData
  - Team
  - StatTags

Pawn / Character
  - 当前这条命
  - 移动
  - 动画表现
  - 当前 Avatar

Controller
  - 玩家输入来源
  - QuickBar
  - WeaponState
  - 本地玩家相关组件

Inventory / Equipment / CharacterParts
  - 动态列表
  - FastArray
  - Replicated SubObject

这里最重要的一句话是:

PlayerState 代表玩家本人,Pawn 代表玩家当前这条命。

所以 Lyra 把 AbilitySystemComponent 放在 ALyraPlayerState 上,而不是放在 ALyraCharacter 上。这样角色死亡、销毁、重生时,能力系统依然跟着玩家走。


第一条主线:Experience 的同步

Lyra 的玩法内容由 Experience 决定。服务器选择 Experience 后,需要让客户端也加载同一套玩法内容。

核心类是:

1
2
3
ALyraGameState
  -> ULyraExperienceManagerComponent
      -> CurrentExperience

ULyraExperienceManagerComponent 本身是可复制组件:

1
2
3
4
ULyraExperienceManagerComponent::ULyraExperienceManagerComponent(...)
{
    SetIsReplicatedByDefault(true);
}

它里面的 CurrentExperience 是 RepNotify:

1
2
UPROPERTY(ReplicatedUsing=OnRep_CurrentExperience)
TObjectPtr<const ULyraExperienceDefinition> CurrentExperience;

流程是:

1
2
3
4
5
6
Server 选择 Experience
  -> SetCurrentExperience()
  -> CurrentExperience 复制到 Client
  -> Client 触发 OnRep_CurrentExperience()
  -> Client 执行 StartExperienceLoad()
  -> Client 加载同一套 GameFeature / ActionSet / DataAsset

也就是说,Lyra 不是把所有玩法类硬编码进 GameMode,而是先同步一个 Experience,再让所有端根据这个 Experience 去加载玩法资源。


第二条主线:PlayerState 承载长期玩家状态

ALyraPlayerState 是 Lyra 网络同步里最核心的类之一。

构造函数里创建 ASC:

1
2
3
AbilitySystemComponent = ObjectInitializer.CreateDefaultSubobject<ULyraAbilitySystemComponent>(this, TEXT("AbilitySystemComponent"));
AbilitySystemComponent->SetIsReplicated(true);
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

这里有两个关键点:

  • ASC 是复制的:技能、效果、属性相关状态可以通过 GAS 自己的复制系统同步。
  • ReplicationMode 是 Mixed:Owner 获得更完整的 GameplayEffect 信息,其他客户端拿到必要信息,减少带宽。

PlayerState 还复制这些重要字段:

1
2
3
4
5
DOREPLIFETIME_WITH_PARAMS_FAST(ThisClass, PawnData, SharedParams);
DOREPLIFETIME_WITH_PARAMS_FAST(ThisClass, MyPlayerConnectionType, SharedParams);
DOREPLIFETIME_WITH_PARAMS_FAST(ThisClass, MyTeamID, SharedParams);
DOREPLIFETIME_WITH_PARAMS_FAST(ThisClass, MySquadID, SharedParams);
DOREPLIFETIME(ThisClass, StatTags);

其中 PawnData 非常关键。服务器设置 PawnData 后,会给 ASC 授予对应的 AbilitySet:

1
2
3
4
5
6
7
for (const ULyraAbilitySet* AbilitySet : PawnData->AbilitySets)
{
    if (AbilitySet)
    {
        AbilitySet->GiveToAbilitySystem(AbilitySystemComponent, nullptr);
    }
}

所以可以这样理解:

1
2
3
Experience 决定这局是什么玩法
PawnData 决定这个玩家用什么角色配置
AbilitySet 决定这个玩家拥有什么能力、效果、属性

第三条主线:PawnExtension 解决初始化顺序问题

网络游戏里,客户端收到对象的顺序是不稳定的。Pawn 可能先到,PlayerState 可能后到;Controller 可能已经复制了,但 PawnData 还没到。

Lyra 用 ULyraPawnExtensionComponent 管这个问题。

它复制 PawnData:

1
DOREPLIFETIME(ULyraPawnExtensionComponent, PawnData);

客户端收到 PawnData 后:

1
2
3
4
void ULyraPawnExtensionComponent::OnRep_PawnData()
{
    CheckDefaultInitialization();
}

CheckDefaultInitialization() 会推进 InitState:

1
2
3
4
Spawned
  -> DataAvailable
  -> DataInitialized
  -> GameplayReady

只有当 PawnData、Controller、PlayerState 等条件都满足后,Lyra 才初始化 ASC:

1
2
AbilitySystemComponent = InASC;
AbilitySystemComponent->InitAbilityActorInfo(InOwnerActor, Pawn);

这里的 OwnerActor 通常是 PlayerState,AvatarActor 是当前 Pawn。

1
2
ASC Owner  = PlayerState
ASC Avatar = Pawn / Character

这就是为什么 Lyra 的角色重生不会重新创建一整套能力系统,而是把 PlayerState 上的 ASC 重新绑定到新的 Pawn。


第四条主线:Character 的移动同步

Lyra 的移动同步主要还是依赖 Unreal 原生的 CharacterMovementComponent

也就是说:

1
2
3
4
5
6
客户端输入移动
  -> 本地预测
  -> 发送移动数据给服务器
  -> 服务器校验
  -> 必要时校正客户端
  -> 其他客户端看到 Simulated Proxy 的移动

Lyra 在这个基础上额外复制了一个压缩后的加速度:

1
2
UPROPERTY(Transient, ReplicatedUsing = OnRep_ReplicatedAcceleration)
FLyraReplicatedAcceleration ReplicatedAcceleration;

它只复制给模拟代理:

1
DOREPLIFETIME_CONDITION(ThisClass, ReplicatedAcceleration, COND_SimulatedOnly);

原因是本地控制者自己有输入,不需要服务器再告诉自己加速度;真正需要这个数据的是其他客户端上的远端角色,用它来改善动画表现。

Lyra 还定义了 FastSharedReplication

1
2
UFUNCTION(NetMulticast, unreliable)
void FastSharedReplication(const FSharedRepMovement& SharedRepMovement);

这是配合 ReplicationGraph 的快速共享移动路径,用来在默认属性复制被跳过的帧里,仍然可以用 unreliable multicast 发一份轻量移动数据。

不过当前工程配置里:

1
2
[/Script/LyraGame.LyraReplicationGraphSettings]
bDisableReplicationGraph=True

所以默认情况下 RepGraph 是关闭的。代码保留了这条优化路径,但普通运行不一定会启用。


第五条主线:GAS 的网络同步

Lyra 的技能、Buff、伤害、血量都主要走 GAS。

默认 Lyra GameplayAbility 是本地预测的:

1
2
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
NetSecurityPolicy = EGameplayAbilityNetSecurityPolicy::ClientOrServer;

简单理解就是:

1
2
3
客户端先预测执行
  -> 服务端再权威确认
  -> 如果预测不一致,GAS 负责回滚 / 修正

输入链路大概是:

1
2
3
4
5
6
LyraHeroComponent 绑定 EnhancedInput
  -> InputTag Pressed / Released
  -> ULyraAbilitySystemComponent 记录 InputPressedSpecHandles
  -> PlayerController::PostProcessInput()
  -> ASC::ProcessAbilityInput()
  -> TryActivateAbility()

技能激活、技能结束、InputPressed、InputReleased 这些,很多都不是 Lyra 自己手写 RPC,而是 GAS 内部已经处理了预测键、能力句柄和复制事件。


第六条主线:Attribute 怎么同步

Lyra 的生命值在 ULyraHealthSet 里。

HealthMaxHealth 都是 RepNotify:

1
2
3
4
5
UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health)
FGameplayAttributeData Health;

UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_MaxHealth)
FGameplayAttributeData MaxHealth;

复制规则是:

1
2
DOREPLIFETIME_CONDITION_NOTIFY(ULyraHealthSet, Health, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(ULyraHealthSet, MaxHealth, COND_None, REPNOTIFY_Always);

客户端收到后会执行:

1
GAMEPLAYATTRIBUTE_REPNOTIFY(ULyraHealthSet, Health, OldValue);

然后 Lyra 会广播本地事件,比如:

1
2
3
OnHealthChanged
OnMaxHealthChanged
OnOutOfHealth

这些事件再被 UI、死亡逻辑、表现层监听。

需要注意的是,伤害不是直接复制 Damage 这个临时属性。Lyra 的流程是:

1
2
3
4
5
GameplayEffect / Execution 算出 Damage
  -> PostGameplayEffectExecute()
  -> Health = Health - Damage
  -> Damage 清零
  -> Health 复制给客户端

第七条主线:开火同步

Lyra 的远程武器同步很适合拿来理解“客户端手感”和“服务器权威”的分工。

大流程是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
客户端按下开火
  -> 激活 RangedWeapon Ability
  -> 本地 PerformLocalTargeting() 做射线检测
  -> 本地立刻播放开火表现 / hit marker 预测
  -> 构造 GameplayAbilityTargetData
  -> CallServerSetReplicatedTargetData() 发给服务器
  -> 服务器消费 TargetData
  -> 服务器校验并应用 GameplayEffect
  -> HealthSet 改血
  -> Health 复制给所有需要的客户端
  -> 服务器 ClientConfirmTargetData() 回给开火客户端

这里的核心函数是:

1
2
3
4
ULyraGameplayAbility_RangedWeapon::StartRangedWeaponTargeting()
ULyraGameplayAbility_RangedWeapon::PerformLocalTargeting()
ULyraGameplayAbility_RangedWeapon::OnTargetDataReadyCallback()
ULyraWeaponStateComponent::ClientConfirmTargetData()

客户端本地先 trace,是为了手感。服务器最后确认,是为了权威。

所以这条链路可以总结成:

1
2
客户端负责“我感觉我打中了”
服务器负责“这次到底算不算打中”

第八条主线:Inventory 的 FastArray 同步

Lyra 的背包不是普通数组复制,而是 FFastArraySerializer

核心结构是:

1
2
3
4
ULyraInventoryManagerComponent
  -> FLyraInventoryList : FFastArraySerializer
      -> TArray<FLyraInventoryEntry>
          -> ULyraInventoryItemInstance

FLyraInventoryList 使用 FastArray delta 序列化:

1
return FFastArraySerializer::FastArrayDeltaSerialize<FLyraInventoryEntry, FLyraInventoryList>(Entries, DeltaParms, *this);

它的好处是:

  • 不是整个数组每次重发
  • 只同步新增、删除、变化的元素
  • 客户端可以在 PostReplicatedAdd / PreReplicatedRemove 里处理表现

每个物品实例 ULyraInventoryItemInstance 自己也复制:

1
2
DOREPLIFETIME(ThisClass, StatTags);
DOREPLIFETIME(ThisClass, ItemDef);

因为 ItemInstance 是 UObject,不是 Actor,所以 Lyra 还需要把它作为 replicated subobject 注册:

1
AddReplicatedSubObject(Instance);

所以 Inventory 实际上是两层同步:

1
2
FastArray 同步“列表里有哪些物品”
SubObject 同步“每个物品自己的数据”

第九条主线:Equipment 的 FastArray 同步

装备系统和背包系统很像。

1
2
3
4
ULyraEquipmentManagerComponent
  -> FLyraEquipmentList : FFastArraySerializer
      -> TArray<FLyraAppliedEquipmentEntry>
          -> ULyraEquipmentInstance

装备列表复制:

1
DOREPLIFETIME(ThisClass, EquipmentList);

装备实例作为 replicated subobject:

1
AddReplicatedSubObject(Result);

客户端收到装备增加时:

1
2
PostReplicatedAdd()
  -> Entry.Instance->OnEquipped()

客户端收到装备移除时:

1
2
PreReplicatedRemove()
  -> Entry.Instance->OnUnequipped()

这就是为什么你看到装备同步后,客户端会自动生成/销毁对应装备表现。


第十条主线:QuickBar 怎么同步

QuickBar 是装备栏,逻辑比 Inventory / Equipment 更直接。

切换槽位是 Server RPC:

1
2
UFUNCTION(Server, Reliable, BlueprintCallable, Category="Lyra")
void SetActiveSlotIndex(int32 NewIndex);

槽位数组和当前激活槽位是 RepNotify:

1
2
3
4
5
UPROPERTY(ReplicatedUsing=OnRep_Slots)
TArray<TObjectPtr<ULyraInventoryItemInstance>> Slots;

UPROPERTY(ReplicatedUsing=OnRep_ActiveSlotIndex)
int32 ActiveSlotIndex = -1;

流程是:

1
2
3
4
5
6
客户端请求切换槽位
  -> Server RPC: SetActiveSlotIndex()
  -> 服务器修改 ActiveSlotIndex
  -> ActiveSlotIndex 复制到客户端
  -> OnRep_ActiveSlotIndex()
  -> GameplayMessage 通知 UI

这里可以看到 RPC 和 RepNotify 的分工:

1
2
3
RPC 负责“我要切换”
Replicated Property 负责“现在切到哪里了”
OnRep 负责“收到以后刷新表现”

第十一条主线:CharacterParts 外观同步

角色外观部件也是 FastArray。

1
2
3
ULyraPawnComponent_CharacterParts
  -> FLyraCharacterPartList : FFastArraySerializer
      -> FLyraAppliedCharacterPartEntry

它同步的不是简单 Mesh,而是“应用了哪些 CharacterPart”。客户端收到增删改以后,会生成或销毁对应的外观 Actor。

这类数据适合 FastArray,因为它是动态列表,并且经常是增删改,而不是每帧变化。


第十二条主线:Team 同步

队伍信息主要在 PlayerState 上。

1
2
UPROPERTY(ReplicatedUsing=OnRep_MyTeamID)
FGenericTeamId MyTeamID;

服务器修改队伍:

1
2
3
SetGenericTeamId()
  -> MyTeamID = NewTeamID
  -> ConditionalBroadcastTeamChanged()

客户端收到队伍变化:

1
2
OnRep_MyTeamID()
  -> ConditionalBroadcastTeamChanged()

Character 上也有一份 MyTeamID,它跟随 Controller / PlayerState 的队伍,用来让 Pawn 本身也能作为 TeamAgent 被查询。

所以队伍系统大概是:

1
2
3
4
PlayerState 保存玩家队伍
Character 缓存当前 Pawn 队伍
TeamSubsystem 负责查询、比较、伤害判断
AsyncAction_ObserveTeam 负责蓝图/UI 监听变化

第十三条主线:消息不是都靠网络复制

Lyra 很多表现层通知使用 GameplayMessageSubsystem

需要注意:GameplayMessage 本身更像本地消息总线,不等于网络复制系统。

比如 GameState 提供了两个 multicast:

1
2
3
4
5
UFUNCTION(NetMulticast, Unreliable)
void MulticastMessageToClients(const FLyraVerbMessage Message);

UFUNCTION(NetMulticast, Reliable)
void MulticastReliableMessageToClients(const FLyraVerbMessage Message);

客户端收到 multicast 后,再在本地广播 GameplayMessage:

1
UGameplayMessageSubsystem::Get(this).BroadcastMessage(Message.Verb, Message);

也就是说:

1
2
网络层:NetMulticast 把消息送到客户端
本地层:GameplayMessageSubsystem 通知 UI / 逻辑模块

这种设计让 UI 不需要直接依赖 GameState、ASC 或 Weapon 代码。


第十四条主线:ReplicationGraph 在 Lyra 里的定位

ReplicationGraph 是 Unreal 用来优化大量 Actor 复制的系统。普通复制会对每个连接判断很多 Actor 的相关性,而 RepGraph 会提前把 Actor 放进不同节点。

Lyra 自己实现了:

1
2
3
ULyraReplicationGraph
ULyraReplicationGraphNode_AlwaysRelevant_ForConnection
ULyraReplicationGraphNode_PlayerStateFrequencyLimiter

主要思路是:

  • 空间化节点:距离相关 Actor 放到 GridSpatialization2D。
  • 全局 AlwaysRelevant:所有客户端都需要的 Actor。
  • 连接级 AlwaysRelevant:只对某个连接重要的 Actor,比如自己的 Controller、ViewTarget、PlayerState。
  • PlayerState 限频节点:不是每帧复制所有 PlayerState,而是分批复制。
  • FastSharedReplication:配合角色移动做更轻的共享更新。

但是当前默认配置关闭了:

1
bDisableReplicationGraph=True

所以学习 Lyra 时,可以先理解它的设计,再决定是否在自己的项目中打开。


Lyra 用到的 Unreal 网络机制汇总

Lyra 主要用到了这些 Unreal 网络同步机制:

机制 Lyra 中的用途
Replicated / OnRep 同步状态,比如 Health、PawnData、Team、QuickBar
Server RPC 客户端请求服务器做事,比如切换 QuickBar
Client RPC 服务器通知某个客户端,比如命中确认
NetMulticast 服务器广播消息或表现
CharacterMovement 移动预测、服务端校正、模拟代理移动
GAS Replication Ability、GameplayEffect、Attribute、Prediction、TargetData
FastArraySerializer Inventory、Equipment、CharacterParts 动态列表
Replicated SubObject ItemInstance、EquipmentInstance 这种 UObject 复制
Replication Condition OwnerOnly、SkipOwner、SimulatedOnly 等条件复制
Push Model 属性变化时主动标脏,比如 PawnData、TeamID
GameplayMessage 客户端本地解耦通知 UI / 逻辑
GameplayCue GAS 驱动表现,如特效、音效
ReplicationGraph 大规模 Actor 复制优化,Lyra 默认关闭

从源码应该怎么读

如果想自己从代码里追 Lyra 网络同步,我建议按下面顺序读:

1. Experience 同步

1
2
3
4
Source/LyraGame/GameModes/LyraGameState.h
Source/LyraGame/GameModes/LyraGameState.cpp
Source/LyraGame/GameModes/LyraExperienceManagerComponent.h
Source/LyraGame/GameModes/LyraExperienceManagerComponent.cpp

先看 CurrentExperience 怎么从服务器复制到客户端。

2. PlayerState 和 ASC

1
2
3
4
Source/LyraGame/Player/LyraPlayerState.h
Source/LyraGame/Player/LyraPlayerState.cpp
Source/LyraGame/AbilitySystem/LyraAbilitySystemComponent.h
Source/LyraGame/AbilitySystem/LyraAbilitySystemComponent.cpp

重点看 ASC 为什么在 PlayerState 上,以及 PawnData 如何授予 AbilitySet。

3. Pawn 初始化

1
2
3
4
Source/LyraGame/Character/LyraPawnExtensionComponent.h
Source/LyraGame/Character/LyraPawnExtensionComponent.cpp
Source/LyraGame/Character/LyraCharacter.h
Source/LyraGame/Character/LyraCharacter.cpp

重点看 PawnData、Controller、PlayerState 到齐后如何进入 GameplayReady。

4. GAS 属性与伤害

1
2
3
4
Source/LyraGame/AbilitySystem/Attributes/LyraHealthSet.h
Source/LyraGame/AbilitySystem/Attributes/LyraHealthSet.cpp
Source/LyraGame/AbilitySystem/Attributes/LyraCombatSet.h
Source/LyraGame/AbilitySystem/Attributes/LyraCombatSet.cpp

重点看 Health 如何复制,以及 Damage 如何变成 Health 改变。

5. 武器开火

1
2
3
4
Source/LyraGame/Weapons/LyraGameplayAbility_RangedWeapon.h
Source/LyraGame/Weapons/LyraGameplayAbility_RangedWeapon.cpp
Source/LyraGame/Weapons/LyraWeaponStateComponent.h
Source/LyraGame/Weapons/LyraWeaponStateComponent.cpp

重点看 TargetData 如何从客户端发到服务器,以及服务器如何确认命中。

6. 背包和装备

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Source/LyraGame/Inventory/LyraInventoryManagerComponent.h
Source/LyraGame/Inventory/LyraInventoryManagerComponent.cpp
Source/LyraGame/Inventory/LyraInventoryItemInstance.h
Source/LyraGame/Inventory/LyraInventoryItemInstance.cpp
Source/LyraGame/Equipment/LyraEquipmentManagerComponent.h
Source/LyraGame/Equipment/LyraEquipmentManagerComponent.cpp
Source/LyraGame/Equipment/LyraEquipmentInstance.h
Source/LyraGame/Equipment/LyraEquipmentInstance.cpp
Source/LyraGame/Equipment/LyraQuickBarComponent.h
Source/LyraGame/Equipment/LyraQuickBarComponent.cpp

重点看 FastArray 和 replicated subobject。

7. ReplicationGraph

1
2
3
Source/LyraGame/System/LyraReplicationGraph.h
Source/LyraGame/System/LyraReplicationGraph.cpp
Config/DefaultGame.ini

重点看它如何路由 Actor,以及为什么当前默认关闭。


几个容易误解的点

1. GameplayMessage 不是网络复制

GameplayMessage 是本地消息分发。它可以被网络 RPC 触发,但它自己不是网络层。

2. OnRep 不应该做权威逻辑

OnRep 是客户端收到状态后的响应,适合刷新 UI、播放表现、触发本地事件。真正权威修改应该在服务器。

3. Client 预测不等于 Client 权威

Lyra 开火时客户端先 trace,是为了手感;最终是否命中、是否扣血,仍然由服务器决定。

4. UObject 可以复制,但需要挂在可复制 Actor / Component 下

ItemInstance、EquipmentInstance 不是 Actor,但可以通过 replicated subobject 复制。前提是它们挂在 InventoryManager / EquipmentManager 这类可复制组件下面。

5. FastArray 不是万能数组

FastArray 适合动态列表增删改,比如背包、装备、外观。每帧变化的数据,比如移动,就不适合用 FastArray。


最后总结

如果用一句话总结 Lyra 的网络同步:

Lyra 用 PlayerState 保存玩家长期权威状态,用 Pawn 表达当前身体和移动,用 GAS 管能力和属性,用 FastArray 管动态列表,用 RPC 处理请求和确认,用 GameplayMessage 解耦本地表现,用 RepGraph 预留大规模复制优化。

再压缩一下,就是:

1
2
3
4
5
6
7
PlayerState:玩家长期状态
Pawn:当前表现和移动
ASC/GAS:能力、效果、属性、预测
FastArray:背包、装备、外观列表
RPC:请求、确认、广播
OnRep:客户端收到状态后的表现刷新
RepGraph:大规模复制优化

只要能顺着这几条线去读源码,Lyra 的网络同步就不会是一堆散乱的宏,而是一套比较清楚的状态归属和数据流设计。