UE5 Lyra Teams模块深度分析:从队伍创建到伤害判定的完整链路

Lyra Teams模块深度分析:从队伍创建到伤害判定的完整链路

写在前面:队友还是敌人,这是个问题

不知道大家在做多人游戏的时候有没有遇到过这种尴尬------玩家A一刀砍在玩家B身上,结果发现两人是队友,但伤害已经打出去了。或者反过来,明明面对面站着两个敌人,系统却判定他们"同属一个阵营"。

这种"队友误伤"的事故,说到底不是策划没想清楚规则,而是代码层面没有一套严谨的队伍判定链路 。UE引擎自带的IGenericTeamAgentInterface提供了最基础的框架,但真正能在商业项目里跑起来的队伍系统,远不止"给Actor贴个队伍ID"那么简单。

Lyra的Teams模块就是这样一个完整方案------它不光回答了"谁属于哪个队",还把队伍创建、网络同步、显示资产、伤害判定、异步观察这些问题一并打包,形成了一套从"队伍诞生"到"战斗结算"的完整闭环。


目录

  1. 模块总览:一张图看懂Teams模块
  2. 接口层:队伍代理接口的巧妙设计
  3. 核心枢纽:队伍子系统ULyraTeamSubsystem
  4. 队伍的诞生:创建组件ULyraTeamCreationComponent
  5. 队伍的身躯:ALyraTeamInfoBase系列
  6. 队伍的外表:ULyraTeamDisplayAsset显示资产
  7. 异步观察:队伍变化的监听机制
  8. 工具层:静态函数与调试命令
  9. 写在最后

1. 模块总览:一张图看懂Teams模块

1.1 基本信息

属性
模块位置 Source/LyraGame/Teams/
核心类数 11个类(含结构体和枚举)
文件数量 20个文件(10对.h/.cpp)
主要依赖 IGenericTeamAgentInterfaceUWorldSubsystemUGameStateComponentUDataAsset

整个模块横跨了接口层、子系统层、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);
    }
}

这个静态函数做了三件事:

  1. 条件检查:队伍真的变了吗?没变就不广播,避免无意义的通知风暴
  2. 日志记录:带客户端/服务器上下文的日志,调试时一眼就能看到是谁在哪个端上换了队
  3. 委托广播 :通过 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函数之后,编译器在优化时可以直接展开,既没有性能损失,又保持了代码整洁。

这里还有一个值得注意的细节:FGenericTeamIduint8 作为底层类型,意味着它只能支持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));
}

子系统初始化时做了一件"润物细无声"的事------注册作弊管理器扩展。这意味着只要子系统存活,玩家随时可以在命令行里敲 CycleTeamSetTeam 1ListTeams 来调试队伍功能。而且这个注册用了 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对象------系统会自动下钻到能找到队伍信息的那一层。

四层查找的优先级是精心设计的:

  1. 直接接口查询 :如果对象本身实现了 ILyraTeamAgentInterface(比如PlayerState),直接返回------最快路径
  2. Instigator链:很多伤害事件里的"Instigator"才是真正发起者(比如投射物的拥有者),比Actor本身更有意义
  3. TeamInfo自身 :队伍信息Actor需要特殊处理,因为 ALyraTeamInfoBase 没有 实现 ILyraTeamAgentInterface(它是队伍本身,不是队伍的"成员")
  4. 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,不需要占世界空间。

SetTeamIdSetTeamDisplayAsset 都是私有函数,友元授权给了 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 内部通过 FindOrAddSetTeamInfoensure 保证了同一个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一起追踪

ObserveTeamColorsObserveTeam 多了一层兜底逻辑------它不仅监听队伍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);
    }
}

这里的逻辑分三步走:

  1. 先解绑旧的:如果之前的LastBroadcastTeamId有值且与现在的不同,先把旧队伍的DisplayAssetChanged委托上的监听移除
  2. 再广播新的:把当前的队伍信息和显示资产通知所有绑定者
  3. 再绑定新的:在新队伍上监听DisplayAssetChanged,这样以后编辑器中改了队伍颜色也能自动更新

注意这个"先解后绑"的顺序------如果不先解绑,当玩家切换到之前待过的队伍时,旧监听可能还没清掉,导致重复回调。

7.4 SetReadyToDestroy:生命周期安全

cpp 复制代码
void UAsyncAction_ObserveTeamColors::SetReadyToDestroy()
{
    Super::SetReadyToDestroy();

    if (ILyraTeamAgentInterface* TeamInterface = TeamInterfacePtr.Get())
    {
        TeamInterface->GetTeamChangedDelegateChecked().RemoveAll(this);
    }
}

异步动作在销毁前会主动清理所有绑定的委托。这是防止"悬空委托"的关键------如果不清理,被观察对象在异步动作销毁后仍然尝试调用它的成员函数,就会造成use-after-free的崩溃。

底层使用了 TWeakInterfacePtrTWeakObjectPtr,形成了一个双向的安全防护:

  • 从观察者到被观察者:弱指针保护,被观察者销毁了,观察者不会拿到悬空指针
  • 从被观察者到观察者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,减少连线操作;bIsPartOfTeamTeamId 作为输出参数,符合蓝图的使用习惯;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日志------开发期省下的排查时间远超写日志的时间
  • 异步动作的"立即广播一次"模式:观察者可以零延迟拿到当前状态,然后坐等后续变化------比"先查询再监听"少一步操作,也少一个出错的环节
  • 用友元控制修改权限SetTeamIdSetTeamDisplayAsset 是私有的,只有 ULyraTeamCreationComponent 被授权访问------用编译器做"权限检查",比运行时检查更可靠

Teams模块的代码量不大,但每一层都有清晰的职责边界。这也符合Lyra项目的一贯风格------框架搭好,扩展留给具体的游戏项目。不管是想做一个5v5的竞技游戏,还是想做一个阵营对抗的MMO,这层"基础设施"都能直接拿过来用,在这基础上加东西就行了。

相关推荐
1204157137 肖哥3 天前
UE5.7 灯光详解
ue5
狂云歌4 天前
AI写UE的3Dgame
人工智能·3d·ue5·游戏程序
日月云棠8 天前
UE5源码分析之Editor——Blutility模块全面分析
ue5
日月云棠8 天前
UE5源码分析之Editor——BehaviorTreeEditor模块全面分析
ue5
日月云棠8 天前
UE5源码分析之Editor——AssetTagsEditor模块全面分析
ue5
远离UE49 天前
UE5 显存 虚拟内存 深入学习笔记
笔记·学习·ue5
日月云棠9 天前
UE5源码分析之Editor——AnimationSettings模块全面分析
ue5
远离UE410 天前
UE5 SF_VertexShader 如何使用
ue5
电子云与长程纠缠10 天前
UE5 Lyra PocketWorld进行3D内容UI预览 - 上
开发语言·学习·3d·ue5·游戏引擎
电子云与长程纠缠10 天前
UE5 Lyra PocketWorld进行3D内容UI预览 - 下
开发语言·学习·游戏·ui·ue5