Lyra Teams模块深度分析:从队伍创建到伤害判定的完整链路
写在前面:队友还是敌人,这是个问题
不知道大家在做多人游戏的时候有没有遇到过这种尴尬------玩家A一刀砍在玩家B身上,结果发现两人是队友,但伤害已经打出去了。或者反过来,明明面对面站着两个敌人,系统却判定他们"同属一个阵营"。
这种"队友误伤"的事故,说到底不是策划没想清楚规则,而是代码层面没有一套严谨的队伍判定链路 。UE引擎自带的IGenericTeamAgentInterface提供了最基础的框架,但真正能在商业项目里跑起来的队伍系统,远不止"给Actor贴个队伍ID"那么简单。
Lyra的Teams模块就是这样一个完整方案------它不光回答了"谁属于哪个队",还把队伍创建、网络同步、显示资产、伤害判定、异步观察这些问题一并打包,形成了一套从"队伍诞生"到"战斗结算"的完整闭环。
目录
- 模块总览:一张图看懂Teams模块
- 接口层:队伍代理接口的巧妙设计
- 核心枢纽:队伍子系统ULyraTeamSubsystem
- 队伍的诞生:创建组件ULyraTeamCreationComponent
- 队伍的身躯:ALyraTeamInfoBase系列
- 队伍的外表:ULyraTeamDisplayAsset显示资产
- 异步观察:队伍变化的监听机制
- 工具层:静态函数与调试命令
- 写在最后
1. 模块总览:一张图看懂Teams模块
1.1 基本信息
| 属性 | 值 |
|---|---|
| 模块位置 | Source/LyraGame/Teams/ |
| 核心类数 | 11个类(含结构体和枚举) |
| 文件数量 | 20个文件(10对.h/.cpp) |
| 主要依赖 | IGenericTeamAgentInterface、UWorldSubsystem、UGameStateComponent、UDataAsset |
整个模块横跨了接口层、子系统层、Actor层、组件层、数据资产层和异步动作层,总计六个层次,各司其职。代码量不算庞大,但结构非常清晰,非常适合作为"如何在UE5中搭建一套完整队伍系统"的学习范例。
1.2 模块架构图
┌─────────────────────────────────────────────────────────────────────────────┐
│ LyraTeams 模块 │
│ │
│ ┌─────────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ ILyraTeamAgentInterface │ │ ULyraTeamDisplayAsset │ │
│ │ (接口层 - 队伍代理) │ │ (数据层 - 显示资产) │ │
│ │ 继承自 IGenericTeamAgentInterface│ │ 颜色/纹理/标量参数 │ │
│ └──────────────┬──────────────────┘ └────────────────┬─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ULyraTeamSubsystem (核心枢纽) │ │
│ │ - TeamMap : TMap<int32, FLyraTeamTrackingInfo> │ │
│ │ - 队伍注册/注销 - 队伍查找 - 队伍关系比较 │ │
│ │ - 伤害判定 - 标签栈管理 - 显示资产查询 │ │
│ └──────┬───────────────┬──────────────────┬─────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ 创建层 │ │ Actor层 │ │ 异步动作层 │ │
│ │ TeamCreation │ │ TeamInfo │ │ AsyncAction_ObserveTeam │ │
│ │ Component │ │ Base/Public │ │ AsyncAction_ObserveTeamColors│ │
│ │ │ │ /Private │ │ │ │
│ └──────────────┘ └─────────────┘ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 工具层: ULyraTeamStatics (蓝图函数库) + ULyraTeamCheats (调试命令) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
1.3 各层职责速览
| 层次 | 核心类 | 一句话职责 |
|---|---|---|
| 接口层 | ILyraTeamAgentInterface |
定义"可拥有队伍"的对象契约------谁实现了这个接口,谁就能被队伍系统识别 |
| 核心枢纽层 | ULyraTeamSubsystem |
队伍系统的"大脑",所有队伍信息都在这里汇聚、查询、比较 |
| 创建层 | ULyraTeamCreationComponent |
队伍的"接生婆",负责在体验加载完成后创建队伍并分配玩家 |
| Actor层 | ALyraTeamInfoBase / PublicInfo / PrivateInfo |
队伍的"实体化身",携带网络同步的队伍数据 |
| 数据层 | ULyraTeamDisplayAsset |
队伍的"外貌描述",纯数据资产,可脱离代码由美术/策划配置 |
| 异步动作层 | AsyncAction_ObserveTeam / ObserveTeamColors |
队伍的"监听器",让蓝图也能优雅地响应队伍变化 |
| 工具层 | ULyraTeamStatics / ULyraTeamCheats |
蓝图辅助函数 + 调试作弊命令 |
1.4 模块间依赖关系
ULyraTeamCreationComponent
├── 依赖 ULyraExperienceManagerComponent (等体验加载完再创建队伍)
├── 依赖 ALyraGameMode (监听新玩家登录)
└── 创建 → ALyraTeamPublicInfo / ALyraTeamPrivateInfo
│
└── 注册到 → ULyraTeamSubsystem
│
├── 被 ULyraTeamStatics 查询
├── 被 ULyraTeamCheats 操作
└── 被 AsyncAction_ObserveTeam* 监听
有趣的是,依赖方向是从创建层→Actor层→子系统层 ,而查询方向正好相反------使用者→子系统层→Actor层。这是一种"生产者-消费者"模式的变体:创建时数据从外向内流,查询时数据从内向外流。子系统就是那个"中间仓库"。
1.5 核心数据流:一个队伍的一生
体验加载完成
│
▼
ULyraTeamCreationComponent::OnExperienceLoaded()
│
├─ ServerCreateTeams()
│ │
│ └─ 遍历 TeamsToCreate 配置Map
│ │
│ └─ ServerCreateTeam(TeamId, DisplayAsset)
│ │
│ ├─ SpawnActor→ALyraTeamPublicInfo
│ │ ├─ SetTeamId(TeamId) → 触发 TryRegisterWithTeamSubsystem()
│ │ └─ SetTeamDisplayAsset(...) → 触发 TryRegisterWithTeamSubsystem()
│ │
│ └─ SpawnActor→ALyraTeamPrivateInfo
│ └─ SetTeamId(TeamId) → 触发 TryRegisterWithTeamSubsystem()
│
└─ ServerAssignPlayersToTeams()
│
├─ 遍历已有PlayerState → ServerChooseTeamForPlayer()
│ └─ GetLeastPopulatedTeamID() → SetGenericTeamId()
│
└─ 注册 GameMode::OnGameModePlayerInitialized 回调
└─ 新玩家登录 → ServerChooseTeamForPlayer()
2. 接口层:队伍代理接口的巧妙设计
2.1 站在巨人的肩膀上
ILyraTeamAgentInterface 继承自 UE5 内置的 IGenericTeamAgentInterface。这个选择其实很讲究------不是另起炉灶自己从头造,而是在引擎已有的"地基"上加盖"楼层"。
好处非常直接:
- 引擎兼容 :UE的AI感知系统(
UAIPerceptionSystem)本身就认得IGenericTeamAgentInterface,队友AI的友好判定天然就能工作 - 最低侵入 :已有实现
IGenericTeamAgentInterface的类(比如ALyraPlayerState)几乎不需要改动 - 语义清晰:Lyra层的接口做的事情就是"在引擎队伍接口之上,提供队伍变化的通知能力"
cpp
// LyraTeamAgentInterface.h
UINTERFACE(meta=(CannotImplementInterfaceInBlueprint))
class ULyraTeamAgentInterface : public UGenericTeamAgentInterface
{
GENERATED_UINTERFACE_BODY()
};
class LYRAGAME_API ILyraTeamAgentInterface : public IGenericTeamAgentInterface
{
GENERATED_IINTERFACE_BODY()
virtual FOnLyraTeamIndexChangedDelegate* GetOnTeamIndexChangedDelegate() { return nullptr; }
static void ConditionalBroadcastTeamChanged(
TScriptInterface<ILyraTeamAgentInterface> This,
FGenericTeamId OldTeamID,
FGenericTeamId NewTeamID);
FOnLyraTeamIndexChangedDelegate& GetTeamChangedDelegateChecked()
{
FOnLyraTeamIndexChangedDelegate* Result = GetOnTeamIndexChangedDelegate();
check(Result);
return *Result;
}
};
2.2 GetOnTeamIndexChangedDelegate:订阅队伍变化的钩子
这是整个接口中最关键的扩展点。IGenericTeamAgentInterface 只管"存队伍ID"和"取队伍ID",但队伍ID变了怎么办?引擎没有管这个。Lyra加了一个GetOnTeamIndexChangedDelegate()虚函数:
- 默认返回
nullptr:不是所有实现了这个接口的对象都支持"队伍变化通知"。比如一个临时的投射物Actor,它可能只需要查询当前所属队伍,但永远不需要"更换队伍",那就没必要实现这个函数。 - 需要广播的对象重写它 :比如
ALyraPlayerState就重写了这个函数,返回一个有效的委托指针。当玩家换队时,所有监听者立刻得到通知。
2.3 ConditionalBroadcastTeamChanged:封装的广播逻辑
cpp
void ILyraTeamAgentInterface::ConditionalBroadcastTeamChanged(
TScriptInterface<ILyraTeamAgentInterface> This,
FGenericTeamId OldTeamID,
FGenericTeamId NewTeamID)
{
if (OldTeamID != NewTeamID)
{
const int32 OldTeamIndex = GenericTeamIdToInteger(OldTeamID);
const int32 NewTeamIndex = GenericTeamIdToInteger(NewTeamID);
UObject* ThisObj = This.GetObject();
UE_LOG(LogLyraTeams, Verbose, TEXT("[%s] %s assigned team %d"),
*GetClientServerContextString(ThisObj), *GetPathNameSafe(ThisObj), NewTeamIndex);
This.GetInterface()->GetTeamChangedDelegateChecked().Broadcast(
ThisObj, OldTeamIndex, NewTeamIndex);
}
}
这个静态函数做了三件事:
- 条件检查:队伍真的变了吗?没变就不广播,避免无意义的通知风暴
- 日志记录:带客户端/服务器上下文的日志,调试时一眼就能看到是谁在哪个端上换了队
- 委托广播 :通过
GetTeamChangedDelegateChecked()拿到委托并广播
注意 GetTeamChangedDelegateChecked() ------它直接 check(Result)。这意味着如果你要用 ConditionalBroadcastTeamChanged,你的类必须 重写 GetOnTeamIndexChangedDelegate() 返回有效指针,否则直接Crash。这是一种"文档写在代码里"的做法:设计者通过断言传达了一个强烈信号------"这个函数不是给你随便用的,调用它的类必须配齐队伍变化通知机制"。
2.4 队伍ID的类型转换:两个不起眼但重要的inline函数
cpp
inline int32 GenericTeamIdToInteger(FGenericTeamId ID)
{
return (ID == FGenericTeamId::NoTeam) ? INDEX_NONE : (int32)ID;
}
inline FGenericTeamId IntegerToGenericTeamId(int32 ID)
{
return (ID == INDEX_NONE) ? FGenericTeamId::NoTeam : FGenericTeamId((uint8)ID);
}
这两个函数很小,但在整个模块中无处不在。它们解决的是一个"阻抗不匹配"问题:
FGenericTeamId是引擎的类型,本质是uint8,用NoTeam(255)表示"无队伍",最多支持255个队伍int32是Lyra内部流通的类型,用INDEX_NONE(-1)表示"无队伍"
如果没有这两个转换函数,到处都要写 (ID == 255) ? -1 : (int32)ID 这种重复代码。写成inline函数之后,编译器在优化时可以直接展开,既没有性能损失,又保持了代码整洁。
这里还有一个值得注意的细节:FGenericTeamId 用 uint8 作为底层类型,意味着它只能支持0到254(除NoTeam的255)共255个队伍。如果你的游戏需要超过这个数,这里就是一个潜在的瓶颈------不过在绝大多数场景下,255个队伍已经绰绰有余。
3. 核心枢纽:队伍子系统ULyraTeamSubsystem
ULyraTeamSubsystem 是整个Teams模块的心脏。它继承自 UWorldSubsystem,意味着每个World都有自己的队伍子系统实例,生命周期跟World绑定。这是正确的选择------队伍信息本身就应该跟World生命周期一致,关卡切了,队伍也应当重新开始。
3.1 核心数据结构:FLyraTeamTrackingInfo
cpp
USTRUCT()
struct FLyraTeamTrackingInfo
{
GENERATED_BODY()
public:
UPROPERTY()
TObjectPtr<ALyraTeamPublicInfo> PublicInfo = nullptr;
UPROPERTY()
TObjectPtr<ALyraTeamPrivateInfo> PrivateInfo = nullptr;
UPROPERTY()
TObjectPtr<ULyraTeamDisplayAsset> DisplayAsset = nullptr;
UPROPERTY()
FOnLyraTeamDisplayAssetChangedDelegate OnTeamDisplayAssetChanged;
};
每个已注册的队伍在 TeamMap 中都对应一个 FLyraTeamTrackingInfo。这个结构体实际上充当了"队伍的聚合缓存"------它:
- 持有PublicInfo和PrivateInfo的指针:快速访问队伍的公共/私有数据,不用每次都去World里查找Actor
- 缓存DisplayAsset:显示资产是从PublicInfo里提取出来的,但每次查询都要走一遍取指针的逻辑太慢,所以直接缓存下来
- 提供DisplayAsset变化委托:当显示资产发生变化(比如编辑器里改了颜色),所有关心这个队伍外观的组件都能立刻收到通知
SetTeamInfo() 函数里有段逻辑很有意思:
cpp
void FLyraTeamTrackingInfo::SetTeamInfo(ALyraTeamInfoBase* Info)
{
if (ALyraTeamPublicInfo* NewPublicInfo = Cast<ALyraTeamPublicInfo>(Info))
{
ensure((PublicInfo == nullptr) || (PublicInfo == NewPublicInfo));
PublicInfo = NewPublicInfo;
ULyraTeamDisplayAsset* OldDisplayAsset = DisplayAsset;
DisplayAsset = NewPublicInfo->GetTeamDisplayAsset();
if (OldDisplayAsset != DisplayAsset)
{
OnTeamDisplayAssetChanged.Broadcast(DisplayAsset);
}
}
// ...
}
注意这个 ensure ------它保证了同一个TeamId不会被注册两次不同的PublicInfo。但这里有个巧妙之处:如果网络复制导致同一个PublicInfo被"重复注册"(比如客户端先通过OnRep收到了,然后BeginPlay又注册了一次),这个ensure不会触发,因为 PublicInfo == NewPublicInfo 成立。只有真正的"冲突"------两个不同的PublicInfo声称代表同一个队伍------才会触发警告。
3.2 初始化:偷偷挂上调试命令
cpp
void ULyraTeamSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
auto AddTeamCheats = [](UCheatManager* CheatManager)
{
CheatManager->AddCheatManagerExtension(NewObject<ULyraTeamCheats>(CheatManager));
};
CheatManagerRegistrationHandle =
UCheatManager::RegisterForOnCheatManagerCreated(
FOnCheatManagerCreated::FDelegate::CreateLambda(AddTeamCheats));
}
子系统初始化时做了一件"润物细无声"的事------注册作弊管理器扩展。这意味着只要子系统存活,玩家随时可以在命令行里敲 CycleTeam、SetTeam 1、ListTeams 来调试队伍功能。而且这个注册用了 RegisterForOnCheatManagerCreated 回调,即使CheatManager还没创建(比如刚进游戏时),回头创建的时候也会自动挂上。
在 Deinitialize() 中相应地取消注册,防止旧的子系统残留对CheatManager的引用。
3.3 FindTeamFromObject:逐层下钻的队伍查找
cpp
int32 ULyraTeamSubsystem::FindTeamFromObject(const UObject* TestObject) const
{
// 第一层:直接问对象自己
if (const ILyraTeamAgentInterface* ObjectWithTeamInterface =
Cast<ILyraTeamAgentInterface>(TestObject))
{
return GenericTeamIdToInteger(ObjectWithTeamInterface->GetGenericTeamId());
}
if (const AActor* TestActor = Cast<const AActor>(TestObject))
{
// 第二层:问发起者(Instigator)
if (const ILyraTeamAgentInterface* InstigatorWithTeamInterface =
Cast<ILyraTeamAgentInterface>(TestActor->GetInstigator()))
{
return GenericTeamIdToInteger(InstigatorWithTeamInterface->GetGenericTeamId());
}
// 第三层:特殊处理TeamInfo本身
if (const ALyraTeamInfoBase* TeamInfo = Cast<ALyraTeamInfoBase>(TestActor))
{
return TeamInfo->GetTeamId();
}
// 第四层:回退到PlayerState
if (const ALyraPlayerState* LyraPS = FindPlayerStateFromActor(TestActor))
{
return LyraPS->GetTeamId();
}
}
return INDEX_NONE;
}
这个函数虽然不长,但它是整个队伍系统的"找队引擎"。调用方可以传入任意对象------Pawn、Controller、PlayerState、投射物、甚至一个UI对象------系统会自动下钻到能找到队伍信息的那一层。
四层查找的优先级是精心设计的:
- 直接接口查询 :如果对象本身实现了
ILyraTeamAgentInterface(比如PlayerState),直接返回------最快路径 - Instigator链:很多伤害事件里的"Instigator"才是真正发起者(比如投射物的拥有者),比Actor本身更有意义
- TeamInfo自身 :队伍信息Actor需要特殊处理,因为
ALyraTeamInfoBase没有 实现ILyraTeamAgentInterface(它是队伍本身,不是队伍的"成员") - PlayerState回退:万能兜底方案,通过Pawn→PlayerState或Controller→PlayerState找到关联的玩家队伍
一个被注释掉的部分也值得注意------原本的设计还尝试了递归查找Instigator:
cpp
// Try the instigator
// if (AActor* Instigator = PossibleTeamActor->GetInstigator())
// { ... }
这段代码被注释掉可能有两个原因:一是递归可能造成无限循环,二是多了一层Instigator后语义变得模糊------"这个投射物A的Instigator是投射物B,但B的Instigator是谁?"不如扁平化处理来得清晰。
3.4 CanCauseDamage:伤害判定的完整逻辑
cpp
bool ULyraTeamSubsystem::CanCauseDamage(
const UObject* Instigator, const UObject* Target, bool bAllowDamageToSelf) const
{
if (bAllowDamageToSelf)
{
if ((Instigator == Target) ||
(FindPlayerStateFromActor(Cast<AActor>(Instigator)) ==
FindPlayerStateFromActor(Cast<AActor>(Target))))
{
return true;
}
}
int32 InstigatorTeamId;
int32 TargetTeamId;
const ELyraTeamComparison Relationship =
CompareTeams(Instigator, Target, InstigatorTeamId, TargetTeamId);
if (Relationship == ELyraTeamComparison::DifferentTeams)
{
return true;
}
else if ((Relationship == ELyraTeamComparison::InvalidArgument) &&
(InstigatorTeamId != INDEX_NONE))
{
return UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(
Cast<const AActor>(Target)) != nullptr;
}
return false;
}
这个函数是伤害系统的"门卫"。它的判定逻辑其实反映了Lyra的设计哲学:
| 条件 | 结果 | 意图 |
|---|---|---|
| 同对象/同PlayerState + 允许自我伤害 | ✅ 允许 | 比如手雷炸自己 |
| 不同队伍 | ✅ 允许 | 敌对玩家之间正常战斗 |
| 目标无队伍 + 发起者有队伍 + 目标有ASC | ✅ 允许 | 兼容PVE场景(打怪物/假人) |
| 其他情况(同队、双方都无队伍等) | ❌ 拒绝 | 防止队友误伤 |
特别值得注意那条"兼容PVE"的逻辑:如果目标没有队伍(InvalidArgument),但拥有能力系统组件,就允许伤害。代码注释里也诚实地说这是"临时方案"------目标练习假人需要一个正式队伍ID而不是靠ASC来特判。但这种"先让它跑起来"的实用主义态度,在真实项目中反而比"一步到位"的完美设计更常见。
3.5 标签栈管理:队伍级别的GameplayTag
cpp
void ULyraTeamSubsystem::AddTeamTagStack(int32 TeamId, FGameplayTag Tag, int32 StackCount)
{
auto FailureHandler = [&](const FString& ErrorMessage)
{
UE_LOG(LogLyraTeams, Error, TEXT("AddTeamTagStack(TeamId: %d, Tag: %s, StackCount: %d) %s"),
TeamId, *Tag.ToString(), StackCount, *ErrorMessage);
};
if (FLyraTeamTrackingInfo* Entry = TeamMap.Find(TeamId))
{
if (Entry->PublicInfo)
{
if (Entry->PublicInfo->HasAuthority())
{
Entry->PublicInfo->TeamTags.AddStack(Tag, StackCount);
}
else { FailureHandler(TEXT("failed because it was called on a client")); }
}
else { FailureHandler(TEXT("failed because there is no team info spawned yet")); }
}
else { FailureHandler(TEXT("failed because it was passed an unknown team id")); }
}
这个函数的防御性编程风格非常值得学习------三个层层递进的if检查,每个失败分支都有清晰的错误日志。这种模式的成本很低(几行日志代码),但在开发阶段遇到问题的时候,不用猜"为什么不生效",日志直接告诉你原因。
标签栈的实际存储位置是 ALyraTeamInfoBase::TeamTags(一个 FGameplayTagStackContainer),并且标记为 Replicated。这意味着队伍标签会通过网络自动同步到所有客户端,客户端可以据此做UI显示、特效判断等逻辑。
GetTeamTagStackCount 还有一个小细节------它把PublicInfo和PrivateInfo的标签栈计数相加:
cpp
int32 PublicStackCount = (Entry->PublicInfo != nullptr) ?
Entry->PublicInfo->TeamTags.GetStackCount(Tag) : 0;
int32 PrivateStackCount = (Entry->PrivateInfo != nullptr) ?
Entry->PrivateInfo->TeamTags.GetStackCount(Tag) : 0;
return PublicStackCount + PrivateStackCount;
这意味着一支队伍可以同时拥有"所有人都能看到的标签"(存在PublicInfo上)和"只有服务器知道的标签"(存在PrivateInfo上),而查询时两者叠加。比如"队伍处于战斗状态"可能是一个公共标签(所有人都能看到),而"队伍获得了隐藏增益"可能是一个私有标签(只有服务器知道,用于计算伤害加成)。
4. 队伍的诞生:创建组件ULyraTeamCreationComponent
4.1 创建时机:挂在体验加载之后
cpp
void ULyraTeamCreationComponent::BeginPlay()
{
Super::BeginPlay();
AGameStateBase* GameState = GetGameStateChecked<AGameStateBase>();
ULyraExperienceManagerComponent* ExperienceComponent =
GameState->FindComponentByClass<ULyraExperienceManagerComponent>();
check(ExperienceComponent);
ExperienceComponent->CallOrRegister_OnExperienceLoaded_HighPriority(
FOnLyraExperienceLoaded::FDelegate::CreateUObject(
this, &ThisClass::OnExperienceLoaded));
}
队伍创建不直接在 BeginPlay 里执行,而是挂在 OnExperienceLoaded 回调上,而且是 HighPriority。这一点反映了Lyra整体架构的设计原则------体验(Experience)是一切游戏逻辑的"起点开关",队伍系统只是众多等待体验加载完成后再启动的子系统之一。
使用HighPriority意味着队伍会在大多数其他系统之前初始化好,避免了"玩家已经登录但还没有队伍可以加入"的尴尬空窗期。
4.2 ServerCreateTeams:生成队伍的实体Actor
cpp
void ULyraTeamCreationComponent::ServerCreateTeam(
int32 TeamId, ULyraTeamDisplayAsset* DisplayAsset)
{
check(HasAuthority());
UWorld* World = GetWorld();
check(World);
FActorSpawnParameters SpawnInfo;
SpawnInfo.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
ALyraTeamPublicInfo* NewTeamPublicInfo =
World->SpawnActor<ALyraTeamPublicInfo>(PublicTeamInfoClass, SpawnInfo);
checkf(NewTeamPublicInfo != nullptr, ...);
NewTeamPublicInfo->SetTeamId(TeamId);
NewTeamPublicInfo->SetTeamDisplayAsset(DisplayAsset);
ALyraTeamPrivateInfo* NewTeamPrivateInfo =
World->SpawnActor<ALyraTeamPrivateInfo>(PrivateTeamInfoClass, SpawnInfo);
checkf(NewTeamPrivateInfo != nullptr, ...);
NewTeamPrivateInfo->SetTeamId(TeamId);
}
每支队伍会生成两个Actor------一个Public和一个Private。为什么是Actor?
因为 AInfo(TeamInfoBase的父类)天生就适合在网络环境中充当"信息载体":它可以被复制(replicated),可以跨网络同步,生命周期由World管理,比纯UObject更适合承载"队伍的运行时状态"。
而且 SpawnCollisionHandlingOverride = AlwaysSpawn 确保这些Actor不会因为碰撞检查失败而生成失败------它们根本不是物理Actor,不需要占世界空间。
SetTeamId 和 SetTeamDisplayAsset 都是私有函数,友元授权给了 ULyraTeamCreationComponent,外部代码无法随意修改队伍数据。这是一种"经批准的修改者"模式------只有特定的创建组件有权动队伍的核心字段。
4.3 ServerChooseTeamForPlayer:最简单的自动平衡
cpp
void ULyraTeamCreationComponent::ServerChooseTeamForPlayer(ALyraPlayerState* PS)
{
if (PS->IsOnlyASpectator())
{
PS->SetGenericTeamId(FGenericTeamId::NoTeam);
}
else
{
const FGenericTeamId TeamID =
IntegerToGenericTeamId(GetLeastPopulatedTeamID());
PS->SetGenericTeamId(TeamID);
}
}
Lyra的自动队伍分配采用了最直观的策略:总是加入人数最少的队伍 。GetLeastPopulatedTeamID 统计每个队伍的活跃玩家数,找出最"瘦"的那个(人数相同时选TeamId最小的)。
这个策略对开发体验和快速测试非常友好------你不需要纠结"选哪个队",系统直接帮你平衡。对于正式发布的游戏,这里通常会扩展为更复杂的策略(考虑技能等级、组队关系等),但现阶段这个朴素策略足够了。
还有一处容易忽略的细节:IsInactive() 的禁用玩家不计数。这意味着掉线的玩家不会占着队伍的"名额",新加入的玩家仍然会被分配到需要补充人数的队伍。
5. 队伍的身躯:ALyraTeamInfoBase系列
5.1 三层继承体系
AInfo
└── ALyraTeamInfoBase (Abstract)
├── ALyraTeamPublicInfo --- 所有人都能看到
└── ALyraTeamPrivateInfo --- 只有服务器能看到(理论上)
这个继承设计遵循一个很清晰的思路:公共信息(队伍ID、标签栈)放在基类,DisplayAsset放在Public子类,Private子类目前只是个占位符(注释写着"用ReplicationGraph实现真正私密")。
5.2 网络复制策略
cpp
void ALyraTeamInfoBase::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, TeamTags);
DOREPLIFETIME_CONDITION(ThisClass, TeamId, COND_InitialOnly);
}
这里有个精妙的细节:TeamId 用的是 COND_InitialOnly,而 TeamTags 用的是默认条件。为什么呢?
- TeamId不会变:一支队伍的TeamId在创建时就固定了,后续永远不会改。所以只需要在客户端初次同步时发一次就够了,之后不用浪费带宽。
- TeamTags会动态变化:标签栈是运行时逻辑(比如队伍获得了某种buff),可能随时增加或减少,所以需要持续复制。
而 ALyraTeamPublicInfo 追加了一个 TeamDisplayAsset,也用 COND_InitialOnly:
cpp
DOREPLIFETIME_CONDITION(ThisClass, TeamDisplayAsset, COND_InitialOnly);
这说明在Lyra当前的设计中,队伍的显示资产也是创建时就确定、后续不变的。
5.3 SetTeamId:带状态锁的赋值
cpp
void ALyraTeamInfoBase::SetTeamId(int32 NewTeamId)
{
check(HasAuthority());
check(TeamId == INDEX_NONE); // 只能从未设置→设置,不能修改
check(NewTeamId != INDEX_NONE); // 不能设置为"无队伍"
TeamId = NewTeamId;
TryRegisterWithTeamSubsystem();
}
三个check把SetTeamId锁定为一个"一次性操作":只能调用一次,只能设置有效值。试图第二次设置同一个Actor的TeamId会被断言拦截。这是一种"防呆设计"------用断言强制正确的使用方式,避免因为误用导致难以追踪的bug。
5.4 TryRegisterWithTeamSubsystem:延迟注册机制
cpp
void ALyraTeamInfoBase::TryRegisterWithTeamSubsystem()
{
if (TeamId != INDEX_NONE)
{
ULyraTeamSubsystem* TeamSubsystem =
GetWorld()->GetSubsystem<ULyraTeamSubsystem>();
if (ensure(TeamSubsystem))
{
RegisterWithTeamSubsystem(TeamSubsystem);
}
}
}
这个函数被调用的场景有多个:
SetTeamId时(服务器端设置了TeamId)OnRep_TeamId时(客户端收到了TeamId复制)BeginPlay时(可能是延迟生成的Actor)OnRep_TeamDisplayAsset时(PublicInfo收到了DisplayAsset复制)
这里的一个关键设计是"幂等性"------不管调用多少次,RegisterWithTeamSubsystem 内部通过 FindOrAdd 和 SetTeamInfo 的 ensure 保证了同一个TeamInfo不会被重复注册出问题。这种"在多处调用、靠内部保证安全"的模式在分布式系统中非常常见。
6. 队伍的外表:ULyraTeamDisplayAsset显示资产
6.1 DataAsset的妙用
cpp
UCLASS(BlueprintType)
class ULyraTeamDisplayAsset : public UDataAsset
{
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TMap<FName, float> ScalarParameters;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TMap<FName, FLinearColor> ColorParameters;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TMap<FName, TObjectPtr<UTexture>> TextureParameters;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText TeamShortName;
};
这个类充分体现了"数据驱动设计"的优势。与其在C++代码里写一堆 if (TeamId == 1) Color = Red; else Color = Blue;,不如把一切配置化:
- 策划/美术在编辑器里直接创建一个DisplayAsset,填好颜色、纹理、短名称
- 代码里只需要问"这个队伍的外观参数是什么",然后应用到材质/粒子/UI上
- 新增一支队伍=新增一个DataAsset文件,一行代码不用改
6.2 ApplyTo系列:外观分发机制
cpp
void ULyraTeamDisplayAsset::ApplyToMeshComponent(UMeshComponent* MeshComponent)
{
if (MeshComponent)
{
for (const auto& KVP : ScalarParameters)
MeshComponent->SetScalarParameterValueOnMaterials(KVP.Key, KVP.Value);
for (const auto& KVP : ColorParameters)
MeshComponent->SetVectorParameterValueOnMaterials(KVP.Key, FVector(KVP.Value));
const TArray<UMaterialInterface*> MaterialInterfaces = MeshComponent->GetMaterials();
for (int32 MaterialIndex = 0; MaterialIndex < MaterialInterfaces.Num(); ++MaterialIndex)
{
if (UMaterialInterface* MaterialInterface = MaterialInterfaces[MaterialIndex])
{
UMaterialInstanceDynamic* DynamicMaterial =
Cast<UMaterialInstanceDynamic>(MaterialInterface);
if (!DynamicMaterial)
{
DynamicMaterial = MeshComponent->CreateAndSetMaterialInstanceDynamic(MaterialIndex);
}
for (const auto& KVP : TextureParameters)
DynamicMaterial->SetTextureParameterValue(KVP.Key, KVP.Value);
}
}
}
}
ApplyTo系列函数解决了同一个外观参数需要分发到不同组件类型的问题:
| 方法 | 目标 | 处理方式 |
|---|---|---|
ApplyToMaterial |
单个动态材质实例 | 直接设置标量/向量/纹理参数 |
ApplyToMeshComponent |
Mesh组件(含多个材质槽) | 遍历所有槽位,确保每个材质都是Dynamic Instance,然后应用参数 |
ApplyToNiagaraComponent |
Niagara粒子组件 | 通过Niagara专用API设置变量 |
ApplyToActor |
整个Actor(递归子Actor) | 遍历所有组件,自动判断类型并分发 |
ApplyToMeshComponent 中有一段很实用的处理逻辑------如果材质槽当前不是动态材质实例,它会自动创建:
cpp
UMaterialInstanceDynamic* DynamicMaterial = Cast<UMaterialInstanceDynamic>(MaterialInterface);
if (!DynamicMaterial)
{
DynamicMaterial = MeshComponent->CreateAndSetMaterialInstanceDynamic(MaterialIndex);
}
这意味着就算美术一开始没有创建MID,代码也会在运行时自动处理,不会因为材质实例类型不对而静默失败。
6.3 编辑器修改的实时通知
cpp
#if WITH_EDITOR
void ULyraTeamDisplayAsset::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
for (ULyraTeamSubsystem* TeamSubsystem : TObjectRange<ULyraTeamSubsystem>())
{
TeamSubsystem->NotifyTeamDisplayAssetModified(this);
}
}
#endif
在编辑器里修改DisplayAsset的属性(比如把蓝色队的颜色调暗一点),所有运行中的PIE窗口里的TeamSubsystem都会收到通知,进而通知所有正在监听的UI和Actor更新外观。TObjectRange 遍历了所有活跃的 ULyraTeamSubsystem 实例(包括多个PIE窗口),这个细节在调试时非常方便------不需要重启PIE就能看到颜色调整的效果。
7. 异步观察:队伍变化的监听机制
7.1 为什么需要异步观察动作
假设我们要做一个队伍指示器Widget------显示玩家所属队伍的颜色和队名。最简单的做法是在Tick里每帧查询 TeamSubsystem->GetTeamColor(...),但这是"暴力轮询",浪费CPU。
更好的做法是事件驱动------在队伍变化、或者显示资产变化的时候自动通知Widget更新。Lyra为此设计了两层异步观察器:
| 类 | 监听内容 | 委托签名 |
|---|---|---|
UAsyncAction_ObserveTeam |
队伍ID变化 | (bool bTeamSet, int32 TeamId) |
UAsyncAction_ObserveTeamColors |
队伍ID + 显示资产变化 | (bool bTeamSet, int32 TeamId, const ULyraTeamDisplayAsset* DisplayAsset) |
7.2 ObserveTeam:更简单的队伍监听
cpp
void UAsyncAction_ObserveTeam::Activate()
{
bool bCouldSucceed = false;
int32 CurrentTeamIndex = INDEX_NONE;
if (ILyraTeamAgentInterface* TeamInterface = TeamInterfacePtr.Get())
{
CurrentTeamIndex = GenericTeamIdToInteger(TeamInterface->GetGenericTeamId());
TeamInterface->GetTeamChangedDelegateChecked().AddDynamic(
this, &ThisClass::OnWatchedAgentChangedTeam);
bCouldSucceed = true;
}
OnTeamChanged.Broadcast(CurrentTeamIndex != INDEX_NONE, CurrentTeamIndex);
if (!bCouldSucceed)
{
SetReadyToDestroy();
}
}
设计上有个关键原则:Activate时立即广播一次当前状态,然后等待后续变化。这意味着调用方不需要先手动查询一次再开始监听------绑定OnTeamChanged委托的那一刻,就已经能拿到当前队伍信息了。这是一种"拉取+推送"的组合模式。
如果被观察的对象根本不可能拥有队伍(比如传进来的TeamAgent没有实现接口),bCouldSucceed 为false,异步动作会立即将自己标记为"可以销毁"。这个细节避免了一个"永远不会触发"的异步动作一直占据内存。
7.3 ObserveTeamColors:连带DisplayAsset一起追踪
ObserveTeamColors 比 ObserveTeam 多了一层兜底逻辑------它不仅监听队伍ID的变化,还监听同一队伍DisplayAsset的变化。
cpp
void UAsyncAction_ObserveTeamColors::BroadcastChange(
int32 NewTeam, const ULyraTeamDisplayAsset* DisplayAsset)
{
UWorld* World = GEngine->GetWorldFromContextObject(
TeamInterfaceObj.Get(), EGetWorldErrorMode::LogAndReturnNull);
ULyraTeamSubsystem* TeamSubsystem = UWorld::GetSubsystem<ULyraTeamSubsystem>(World);
const bool bTeamChanged = (LastBroadcastTeamId != NewTeam);
// 从旧队伍解除监听
if ((TeamSubsystem != nullptr) && bTeamChanged && (LastBroadcastTeamId != INDEX_NONE))
{
TeamSubsystem->GetTeamDisplayAssetChangedDelegate(LastBroadcastTeamId)
.RemoveAll(this);
}
// 广播
LastBroadcastTeamId = NewTeam;
OnTeamChanged.Broadcast(NewTeam != INDEX_NONE, NewTeam, DisplayAsset);
// 绑定新队伍的DisplayAsset变化监听
if ((TeamSubsystem != nullptr) && bTeamChanged && (NewTeam != INDEX_NONE))
{
TeamSubsystem->GetTeamDisplayAssetChangedDelegate(NewTeam)
.AddDynamic(this, &ThisClass::OnDisplayAssetChanged);
}
}
这里的逻辑分三步走:
- 先解绑旧的:如果之前的LastBroadcastTeamId有值且与现在的不同,先把旧队伍的DisplayAssetChanged委托上的监听移除
- 再广播新的:把当前的队伍信息和显示资产通知所有绑定者
- 再绑定新的:在新队伍上监听DisplayAssetChanged,这样以后编辑器中改了队伍颜色也能自动更新
注意这个"先解后绑"的顺序------如果不先解绑,当玩家切换到之前待过的队伍时,旧监听可能还没清掉,导致重复回调。
7.4 SetReadyToDestroy:生命周期安全
cpp
void UAsyncAction_ObserveTeamColors::SetReadyToDestroy()
{
Super::SetReadyToDestroy();
if (ILyraTeamAgentInterface* TeamInterface = TeamInterfacePtr.Get())
{
TeamInterface->GetTeamChangedDelegateChecked().RemoveAll(this);
}
}
异步动作在销毁前会主动清理所有绑定的委托。这是防止"悬空委托"的关键------如果不清理,被观察对象在异步动作销毁后仍然尝试调用它的成员函数,就会造成use-after-free的崩溃。
底层使用了 TWeakInterfacePtr 和 TWeakObjectPtr,形成了一个双向的安全防护:
- 从观察者到被观察者:弱指针保护,被观察者销毁了,观察者不会拿到悬空指针
- 从被观察者到观察者 :
RemoveAll保护,观察者销毁了,被观察者的委托列表里不会残留无效引用
8. 工具层:静态函数与调试命令
8.1 ULyraTeamStatics:蓝图友好的查询接口
cpp
UFUNCTION(BlueprintCallable, Category=Teams,
meta=(Keywords="GetTeamFromObject", DefaultToSelf="Agent", AdvancedDisplay="bLogIfNotSet"))
static void FindTeamFromObject(const UObject* Agent, bool& bIsPartOfTeam,
int32& TeamId, ULyraTeamDisplayAsset*& DisplayAsset, bool bLogIfNotSet = false);
这个函数是蓝图中最常用的队伍查询入口。DefaultToSelf="Agent" 让蓝图节点默认传self,减少连线操作;bIsPartOfTeam 和 TeamId 作为输出参数,符合蓝图的使用习惯;bLogIfNotSet 是调试用的"高级选项",默认收起。
还有一个"带兜底"的设计模式出现在三个Get函数中:
cpp
float ULyraTeamStatics::GetTeamScalarWithFallback(
ULyraTeamDisplayAsset* DisplayAsset, FName ParameterName, float DefaultValue)
{
if (DisplayAsset)
{
if (float* pValue = DisplayAsset->ScalarParameters.Find(ParameterName))
{
return *pValue;
}
}
return DefaultValue;
}
这种模式让调用方永远能得到一个有效值------要么是配置的值,要么是调用方指定的默认值。不需要在调用处写null检查和Find判空,出错概率大大降低。
8.2 ULyraTeamCheats:开发期调试利器
cpp
void ULyraTeamCheats::CycleTeam()
{
if (ULyraTeamSubsystem* TeamSubsystem =
UWorld::GetSubsystem<ULyraTeamSubsystem>(GetWorld()))
{
APlayerController* PC = GetPlayerController();
const int32 OldTeamId = TeamSubsystem->FindTeamFromObject(PC);
const TArray<int32> TeamIds = TeamSubsystem->GetTeamIDs();
if (TeamIds.Num())
{
const int32 IndexOfOldTeam = TeamIds.Find(OldTeamId);
const int32 IndexToUse = (IndexOfOldTeam + 1) % TeamIds.Num();
const int32 NewTeamId = TeamIds[IndexToUse];
TeamSubsystem->ChangeTeamForActor(PC, NewTeamId);
}
}
}
CycleTeam 会按队伍ID顺序循环切换------检测当前玩家所在的队伍,然后切换到列表中的下一个队伍。如果当前不在任何队伍中(Find返回INDEX_NONE),IndexOfOldTeam + 1 会变成0,自然切换到第一个队伍。模运算 % TeamIds.Num() 保证了循环。
这三个作弊命令对于开发期间测试队伍功能非常实用:
CycleTeam:快速切换队伍看效果SetTeam 1:精确跳到指定队伍ListTeams:查看当前有哪些队伍
9. 写在最后
把Lyra的Teams模块从头到尾捋一遍之后,最深的感受是:好的游戏系统架构,不是在"写功能",而是在"建设施"。它不追求功能的大而全(比如只有一个最简单的"人数平衡分配"策略),但把基础设施搭得非常扎实------接口有清晰的继承关系,子系统有正确的生命周期管理,数据流有明确的"生产者→仓库→消费者"方向,网络复制有精细的条件控制(COND_InitialOnly vs 默认)。
几个值得在自研项目中借鉴的点:
- 用UWorldSubsystem管理全局状态:不需要全局单例,不需要手动管理生命周期,World销毁时自动清理------比Singleton模式更适合UE
- 用DataAsset分离"逻辑"和"配置" :
ULyraTeamDisplayAsset让策划可以独立配置队伍外观,代码只负责"应用"动作,完全不关心具体是什么颜色 - 防御性编程不等于啰嗦 :
AddTeamTagStack三个if写下来不过10行,但每个失败分支都带着清晰的Error日志------开发期省下的排查时间远超写日志的时间 - 异步动作的"立即广播一次"模式:观察者可以零延迟拿到当前状态,然后坐等后续变化------比"先查询再监听"少一步操作,也少一个出错的环节
- 用友元控制修改权限 :
SetTeamId和SetTeamDisplayAsset是私有的,只有ULyraTeamCreationComponent被授权访问------用编译器做"权限检查",比运行时检查更可靠
Teams模块的代码量不大,但每一层都有清晰的职责边界。这也符合Lyra项目的一贯风格------框架搭好,扩展留给具体的游戏项目。不管是想做一个5v5的竞技游戏,还是想做一个阵营对抗的MMO,这层"基础设施"都能直接拿过来用,在这基础上加东西就行了。