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,这层"基础设施"都能直接拿过来用,在这基础上加东西就行了。

相关推荐
日月云棠3 天前
UE5 Lyra Inventory 物品系统模块深度分析——从数据定义到网络同步的完整链路
ue5
日月云棠4 天前
UE5 Lyra Weapons 模块深度分析——从装备实例到弹道散布的完整武器系统
ue5
清泓y4 天前
UE5程序化生成技术
ue5
清泓y5 天前
UE5--VR与AR开发技术
ue5·ar·vr
日月云棠5 天前
UE5 Lyra Messages 模块深度解析——从一条击杀消息看游戏事件系统的设计哲学
游戏·ue5
日月云棠6 天前
UE5 Lyra Input 模块:从手柄摇杆到技能释放,一条链路如何做到零耦合
ue5
日月云棠9 天前
UE5 Lyra源码分析——Audio_Analysis音频模块全面分析
ue5·音视频
成都渲染101云渲染66669 天前
2026云渲染平台哪个好?建筑、动画、UE5项目选择云渲染的3个关键标准
ue5
清泓y10 天前
UE 物理系统知识分享
面试·ue5·游戏程序