81. UE5 RPG 实现角色升级系统(上)

91 篇文章 13 订阅

在这一篇文章里面,记录一下实现角色升级系统的笔记。
接下来,解释一下我们要实现的方式,我们将记录角色的总经验值,在角色的经验增长达到升级经验时,角色将实现升级,并提供升级奖励。
这里比较注意的点是,我们创建一个升级经验数据资产,里面记录的每一级的升级条件不是当前等级升级所需经验,而是记录的角色升级所需的总经验值。
比如:角色默认等级为1,升级为2级时需要300经验值,数据里记录的是升级所需为300经验。2级升级到3级所需经验为600经验,但是数据记录的是角色达到3级时的总获得的经验,所以数据记录的是900经验值。
这个升级所需经验一般通过手动设置数据,这样会方便策划设置,并且我们还会在数据里面配置角色的升级奖励(奖励的技能点数,以及属性增加点数等等)。
接下来,我们制作的步骤:

  1. 创建升级所需的数据资产
  2. 在PlayerState里存储角色经验
  3. 通过委托广播的方式,实现经验改变后的回调
  4. 在UI上面监听委托,实时更新经验条
  5. 升级时,应用升级的奖励,提供可分配的技能点数和属性点。
  6. 在击杀敌人时,为角色提供经验
  7. 升级功能,处理给予一次经验能够升级多次的问题。

创建数据资产

首先,我们要创建一个角色升级的数据资产,以等级为每一条数据索引,我们在数据总增加一个属性 Level Up Requirement 即到达此等级角色所需的总经验。Attribute Point Reward 到达此等级后,角色属性的奖励点数。Spell Point Reward 到达此等级后,奖励角色可分配技能。
我们首先创建一个新的类,继承DataAsset
在这里插入图片描述
将其命名为LevelUpInfo,用于存储每一级升级的数据
在这里插入图片描述
首先,我们在类里,创建一个结构体,用于存储当前升级所需设置的内容,为开头说的那三项内容

//角色升级数据结构体
USTRUCT(BlueprintType)
struct FRPGLevelUpInfo
{
	GENERATED_BODY()

	UPROPERTY(EditDefaultsOnly)
	int32 LevelUpRequirement = 0; //升到此等级所需经验值

	UPROPERTY(EditDefaultsOnly)
	int32 AttributePointAward = 1; //达到此等级奖励的属性点值

	UPROPERTY(EditDefaultsOnly)
	int32 SpellPointAward = 1; //达到此等级降级的可分配技能点数
};

在类里面,我们创建一个参数用于存放结构体数组,可以对每个等级的数据单独设置,并增加一个通过角色经验值获取当前角色等级的函数。

UCLASS()
class RPG_API ULevelUpInfo : public UDataAsset
{
	GENERATED_BODY()

public:

	UPROPERTY(EditDefaultsOnly)
	TArray<FRPGLevelUpInfo> LevelUpInformation; //当前所有等级的升级数据

	int32 FindLevelForXP(int32 XP); //通过经验值值获取角色的等级
};

在FindLevelForXP函数实现这里,我们采用一个while循环,用来遍历数组,用来获取到当前角色拥有的经验值可以显示的最大等级的数据,并返回当前的等级。

int32 ULevelUpInfo::FindLevelForXP(int32 XP)
{
	int32 Level = 1;
	bool bSearching = true;
	while (bSearching)
	{
		//索引【0】为空数据,索引下标表示对应等级的数据,如果遍历完成了整个数组,说明当前等级已经达到最大值,直接返回当前等级
		if(LevelUpInformation.Num() -1 <= Level) return Level;
		//判断当前经验值是否已达到对应的等级所需的经验值
		if(XP >= LevelUpInformation[Level].LevelUpRequirement)
		{
			Level++; //查询等级数据增加一级
		}
		else
		{
			bSearching = false; //如果经验值没有达到,降停止循环
		}
	}
	return Level;
}

接下来,我们编译打开编辑器,创建升级所需的数据资产
我们创建一个数据资产,然后类型选择我们当前创建的类
在这里插入图片描述
命名为DA_LevelUpInfo
在这里插入图片描述
我们内部添加多个子项,子项前方有索引,可以作为等级使用
在这里插入图片描述
索引0只是占位符,我们不需要设置它的数据,从等级1开始设置角色的升级数据
在这里插入图片描述

在PlayerState上添加经验设置

为什么要加在PlayerState上面,和ASC一个道理,如果将控制角色销毁掉,当前角色也能够保存下来数据。并且还易于和服务器去同步数据。
在PlayerState里,我们要对经验值和等级实现设置,并且实现它们的客户端之间的同步。
首先,我们通过宏创建一个委托,用于在经验值和等级被修改后,更新到UI上面

DECLARE_MULTICAST_DELEGATE_OneParam(FOnPlayerStateChanged, int32);

我们创建两个委托函数,用于在等级或者经验值修改后的回调

	FOnPlayerStateChanged OnXPChangedDelegate; //经验值变动委托
	FOnPlayerStateChanged OnLevelChangedDelegate; //等级变动委托

我们还需要增加一个设置等级的资产文件的地方,选择在这里还是不错的

	UPROPERTY(EditDefaultsOnly)
	TObjectPtr<ULevelUpInfo> LevelUpInfo; //设置升级相关数据

接着,我们创建它们的属性,都是为整型类型的,并创建其它客户端或服务器修改后的同步回调函数

private:

	UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_Level)
	int32 Level = 1.f;

	UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_XP)
	int32 XP = 0.f;

	UFUNCTION()
	void OnRep_Level(int32 OldLevel) const; //服务器出现更改自动同步到本地函数 等级

	UFUNCTION()
	void OnRep_XP(int32 OldXP) const; //服务器出现更改自动同步到本地函数 经验值

接下来,我们给等级和经验值增加添改查的函数,用于操作修改属性

	FORCEINLINE int32 GetPlayerLevel() const {return Level;} //获取角色等级
	void AddToLevel(int32 InLevel); //增加等级
	void SetLevel(int32 InLevel); //设置当前等级

接着就是实现对应的函数,在修改后,我们需要将数值广播出去,在设置里面广播,只能够在当前客户端执行或者服务器上,其它客户端是无法得到对应的设置调用,所以,我们还需要在同步函数中,调用委托触发。

void ARPGPlayerState::AddToLevel(const int32 InLevel)
{
	Level += InLevel;
	OnLevelChangedDelegate.Broadcast(Level);
}

void ARPGPlayerState::SetLevel(const int32 InLevel)
{
	Level = InLevel;
	OnLevelChangedDelegate.Broadcast(Level);
}

void ARPGPlayerState::OnRep_Level(int32 OldLevel) const
{
	OnLevelChangedDelegate.Broadcast(Level); //上面修改委托只会在服务器触发,在此处设置是在服务器更新到客户端本地后触发
}

这样,我们就在PlayerState里面完成了等级和经验的设置。
完整代码如下
RPGPlayerState.h

// 版权归暮志未晚所有。

#pragma once

#include "CoreMinimal.h"
#include "AbilitySystemInterface.h"
#include "GameFramework/PlayerState.h"
#include "RPGPlayerState.generated.h"

class UAbilitySystemComponent;
class UAttributeSet;

DECLARE_MULTICAST_DELEGATE_OneParam(FOnPlayerStateChanged, int32);

/**
 * 玩家状态基类
 */
UCLASS()
class RPG_API ARPGPlayerState : public APlayerState, public IAbilitySystemInterface
{
	GENERATED_BODY()

public:
	ARPGPlayerState();
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
	virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override; //覆盖虚函数获取asc
	UAttributeSet* GetAttributeSet() const { return AttributeSet; } //获取as

	UPROPERTY(EditDefaultsOnly)
	TObjectPtr<ULevelUpInfo> LevelUpInfo; //设置升级相关数据

	FOnPlayerStateChanged OnXPChangedDelegate; //经验值变动委托
	FOnPlayerStateChanged OnLevelChangedDelegate; //等级变动委托

	FORCEINLINE int32 GetPlayerLevel() const {return Level;} //获取角色等级
	void AddToLevel(int32 InLevel); //增加等级
	void SetLevel(int32 InLevel); //设置当前等级

	FORCEINLINE int32 GetXP() const {return XP;} //获取角色当前经验值
	void AddToXP(int32 InXP); //增加经验值
	void SetXP(int32 InXP); //设置当前经验值

protected:
	
	UPROPERTY(VisibleAnywhere)
	TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;

	UPROPERTY()
	TObjectPtr<UAttributeSet> AttributeSet;

private:

	UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_Level)
	int32 Level = 1.f;

	UPROPERTY(VisibleAnywhere, ReplicatedUsing=OnRep_XP)
	int32 XP = 0.f;

	UFUNCTION()
	void OnRep_Level(int32 OldLevel) const; //服务器出现更改自动同步到本地函数 等级

	UFUNCTION()
	void OnRep_XP(int32 OldXP) const; //服务器出现更改自动同步到本地函数 经验值
};

RPGPlayerState.cpp

// 版权归暮志未晚所有。


#include "Player/RPGPlayerState.h"
#include "AbilitySystem/RPGAbilitySystemComponent.h"
#include "AbilitySystem/RPGAttributeSet.h"
#include "Net/UnrealNetwork.h"

ARPGPlayerState::ARPGPlayerState()
{
	AbilitySystemComponent = CreateDefaultSubobject<URPGAbilitySystemComponent>("AbilitySystemComponent");
	AbilitySystemComponent->SetIsReplicated(true); //设置组件用于在网络上复制
	AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

	AttributeSet = CreateDefaultSubobject<URPGAttributeSet>("AttributeSet");
	
	NetUpdateFrequency = 100.f; //每秒和服务器更新频率,使用GAS后可以设置的高一些
}

void ARPGPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	//定义在多人游戏中,需要在网络中复制的属性,当属性发生变化,修改将被发送到其它客户端和服务器
	DOREPLIFETIME(ARPGPlayerState, Level);
	DOREPLIFETIME(ARPGPlayerState, XP);
}

UAbilitySystemComponent* ARPGPlayerState::GetAbilitySystemComponent() const
{
	return AbilitySystemComponent;
}

void ARPGPlayerState::AddToXP(const int32 InXP)
{
	XP += InXP;
	OnXPChangedDelegate.Broadcast(XP);
}

void ARPGPlayerState::SetXP(const int32 InXP)
{
	XP = InXP;
	OnXPChangedDelegate.Broadcast(XP);
}

void ARPGPlayerState::AddToLevel(const int32 InLevel)
{
	Level += InLevel;
	OnLevelChangedDelegate.Broadcast(Level);
}

void ARPGPlayerState::SetLevel(const int32 InLevel)
{
	Level = InLevel;
	OnLevelChangedDelegate.Broadcast(Level);
}

void ARPGPlayerState::OnRep_Level(int32 OldLevel) const
{
	OnLevelChangedDelegate.Broadcast(Level); //上面修改委托只会在服务器触发,在此处设置是在服务器更新到客户端本地后触发
}

void ARPGPlayerState::OnRep_XP(int32 OldXP) const
{
	OnXPChangedDelegate.Broadcast(XP); //上面修改委托只会在服务器触发,在此处设置是在服务器更新到客户端本地后触发
}

在UI控制器实现经验条比例更新委托

接下来,我们在UI的控制器里实现经验条的委托,UI蓝图控件可以监听此委托更新经验条的百分比,显示经验条的所需经验。
我们新增加一个经验修改后,经验条百分比更新的委托

	UPROPERTY(BlueprintAssignable, Category="GAS|XP")
	FOnAttributeChangedSignature OnXPPercentChangedDelegate; //经验条百分比变动回调

然后增加一个监听经验变动后的回调,此回调用于监听经验变动后,重新计算经验百分比

void OnXPChanged(int32 NewXP) const; //经验变动后的回调

接着在绑定监听回调里绑定到PlayerState的回调


	const URPGAttributeSet* AttributeSetBase = CastChecked<URPGAttributeSet>(AttributeSet);
	ARPGPlayerState* RPGPlayerState = CastChecked<ARPGPlayerState>(PlayerState);

	//绑定等级相关回调
	RPGPlayerState->OnXPChangedDelegate.AddUObject(this, &ThisClass::OnXPChanged);

在回调实现这里,我们首先判断资产是否被设置,然后获取等级,根据当前的经验值计算出百分比,并广播出去。这里我们通过等级获得当前等级升级总经验,然后获得上一级升级所需总经验,它们之间的插值就是此等级升级所需经验。

void UOverlayWidgetController::OnXPChanged(int32 NewXP) const
{
	const ARPGPlayerState* RPGPlayerState = CastChecked<ARPGPlayerState>(PlayerState);
	const ULevelUpInfo* LevelUpInfo = RPGPlayerState->LevelUpInfo;
	checkf(LevelUpInfo, TEXT("无法查询到等级相关数据,请查看PlayerState是否设置了对应的数据"));

	const int32 Level =  LevelUpInfo->FindLevelForXP(NewXP); //获取当前等级
	const int32 MaxLevel = LevelUpInfo->LevelUpInformation.Num(); //获取当前最大等级

	if(Level <= MaxLevel && Level > 0)
	{
		const int32 LevelUpRequirement = LevelUpInfo->LevelUpInformation[Level].LevelUpRequirement; //当前等级升级所需经验值
		const int32 PreviousLevelUpRequirement = LevelUpInfo->LevelUpInformation[Level-1].LevelUpRequirement; //上一级升级所需经验值

		const float XPPercent = static_cast<float>((NewXP - PreviousLevelUpRequirement) / (LevelUpRequirement - PreviousLevelUpRequirement)); //计算经验百分比
		OnXPPercentChangedDelegate.Broadcast(XPPercent); //广播经验条比例
	}
}

为敌人增加经验奖励

更新方法有了,现在我们击杀敌人还不能给我们提供经验的奖励,接下来,我们将在敌人的配置里面,为敌人增加死亡时为玩家角色提供的经验值奖励。
实现逻辑是,在敌人死亡时,我们将给玩家角色发送一个事件,来通知角色获得经验,而在玩家这里,我们通过增加一个被动技能GA,这个技能会在玩家存活时保持激活状态,在技能内部监听对应事件来获取经验。
首先,我们要创建一个曲线表格,用于实现不同职业提供不同的经验。
在这里插入图片描述
然后选择插值类型
在这里插入图片描述
命名为 CT_XP_Reward 经验奖励
在这里插入图片描述
然后创建好每一种职业的敌人的经验奖励
在这里插入图片描述
有了数据,我们需要在数据资产里添加对应的配置项,在之前我们创建了敌人的初始化相关使用的数据,我们可以在职业的结构体内增加一项来设置对应职业使用的表格的项
在这里插入图片描述

之前我们使用过两种方式,一种是设置表格,自己从表格中获取行并根据等级获取数值
在这里插入图片描述
另外一种是设置好行,直接通过等级获取数值
在这里插入图片描述
接下来我们在配置项的结构体内增加一项,采用第二种方式
在这里插入图片描述
编译打开,将表格数据配置上去
在这里插入图片描述
接着,在我们的函数库里增加一个通过敌人类型和等级获取经验值的函数

	//获取根据敌人类型和等级获取敌人产生的经验
	UFUNCTION(BlueprintCallable, Category="RPGAbilitySystemLibrary|CharacterClassDefaults")
	static int32 GetXPRewardForClassAndLevel(const UObject* WorldContextObject, ECharacterClass CharacterClass, int32 CharacterLevel);

实现这里,通过之前实现的函数去获取数据,然后获取对应职业的配置,再通过等级获取到数值,转换为整数返回。

int32 URPGAbilitySystemBlueprintLibrary::GetXPRewardForClassAndLevel(const UObject* WorldContextObject, ECharacterClass CharacterClass, int32 CharacterLevel)
{
	//从实例获取到关卡角色的配置
	UCharacterClassInfo* CharacterClassInfo = GetCharacterClassInfo(WorldContextObject);
	if(CharacterClassInfo == nullptr) return 0;

	//获取到默认的基础角色数据
	const FCharacterClassDefaultInfo& ClassDefaultInfo = CharacterClassInfo->GetClassDefaultInfo(CharacterClass);

	const float XPReward = ClassDefaultInfo.XPReward.GetValueAtLevel(CharacterLevel);

	return static_cast<int32>(XPReward);
}

然后我们在战斗接口增加一个函数用于获取当前的角色的职业

	UFUNCTION(BlueprintNativeEvent, BlueprintCallable)
	ECharacterClass GetCharacterClass(); //获取当前角色的职业

将定义角色职业类型的参数移动到角色基类里面

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Character Class Defaults")
	ECharacterClass CharacterClass = ECharacterClass::Warrior;

然后在角色基类里重写接口内获取职业的函数

virtual ECharacterClass GetCharacterClass_Implementation() override;

实现这里直接返回设置的职业即可

ECharacterClass ARPGCharacter::GetCharacterClass_Implementation()
{
	return CharacterClass;
}

由于玩家只有一个职业,我们需要设置其默认为法师职业,所以在构造函数中,设置默认

	//设置玩家职业
	CharacterClass = ECharacterClass::Elementalist;

添加经验元属性

我们之前添加过伤害的元属性,按照同样的逻辑,添加经验的元属性。
在AttributeSet类里面增加一个新的元属性

	UPROPERTY(BlueprintReadOnly, Category="Meta Attributes")
	FGameplayAttributeData IncomingXP; //处理传入的经验
	ATTRIBUTE_ACCESSORS(URPGAttributeSet, IncomingXP);

我们还需要一个元属性标签,后续用于在GE中设置获得的经验值,因为经验值是不确定的,需要使用到SetByCaller去设置

	//元属性
	FGameplayTag Attributes_Meta_IncomingXP; //元属性经验 标签

然后注册

	/*
	 * 元属性
	 */
	
	GameplayTags.Attributes_Meta_IncomingXP = UGameplayTagsManager::Get()
		.AddNativeGameplayTag(
			FName("Attributes.Meta.IncomingXP"),
			FString("经验元属性标签")
			);

添加被动技能监听经验

接下来,我们要实现一个技能用于监听经验的获取,这个技能相当于被动技能,在应用时自动激活。在技能里面监听整个属性事件,这样,不单单可以实现对经验值获取监听,还可以对后续的其它属性进行监听。在监听到对应的属性后,创建一个GE应用给自身,实现对自身的经验增长。
所以,我们先创建一个GE,这个GE需要使用SetbyCaller设置需要应用的经验值,所以我们增加一个标签,通过标签设置经验值。
在GE里,我们设置时间为Instant,通过SetbyCaller去修改创建的元属性。
在这里插入图片描述
然后创建一个技能蓝图
在这里插入图片描述
我们将其命名为GA_ListenForEvent
在这里插入图片描述

接下来我们修改配置,降其设置为每个actor只能实例化一个实例,并且只在服务器运行
在这里插入图片描述

添加一个变量,用于设置使用的GE
在这里插入图片描述
在技能被激活后,添加一个监听事件节点,用于监听所有属性下面的所有标签,这里不能够选择Only Match Exact (全部匹配)
在这里插入图片描述
然后我们通过GE类创建一个GE实例
在这里插入图片描述
然后从事件的附加内容中获取到对应的触发标签和数值,使用SetByCaller,通过标签形式设置给自身
在这里插入图片描述
最后,别忘记将创建的GE设置上去,以下为完整蓝图
在这里插入图片描述

应用被动技能

我们创建可以获取经验的被动技能,接下来,要在初始化角色的时候,将技能应用给角色。
首先,我们在角色基类这里增加一个属性用于设置角色的被动技能

	UPROPERTY(EditAnywhere, Category="Attributes")
	TArray<TSubclassOf<UGameplayAbility>> StartupPassiveAbilities; //角色初始被动技能设置

属性添加现在是初始化完成ASC后,然后进行的技能初始化,调用的是角色基类身上的初始化技能函数。在角色基类的初始化技能函数里面,是通过自定义的ASC里面应用技能实现,所以,我们在里面再添加一个应用被动技能的函数即可。
所以,在自定义的ASC类里,增加一个应用被动技能数组的函数

void AddCharacterPassiveAbilities(const TArray<TSubclassOf<UGameplayAbility>>& StartupPassiveAbilities); //应用被动技能

被动技能和主动技能的区别在于,被动技能应用即激活

void URPGAbilitySystemComponent::AddCharacterPassiveAbilities(const TArray<TSubclassOf<UGameplayAbility>>& StartupPassiveAbilities)
{
	for(const TSubclassOf<UGameplayAbility> AbilityClass : StartupPassiveAbilities)
	{
		FGameplayAbilitySpec AbilitySpec = FGameplayAbilitySpec(AbilityClass, 1);
		GiveAbilityAndActivateOnce(AbilitySpec); //应用技能并激活一次
	}
}

最后在角色基类激活技能这里,调用了应用主动技能,我们下面调用应用被动技能即可

void ARPGCharacter::AddCharacterAbilities() const
{
	URPGAbilitySystemComponent* ASC = CastChecked<URPGAbilitySystemComponent>(GetAbilitySystemComponent());
	if(!HasAuthority()) return; //查询是否拥有网络权限,应用技能需要添加给服务器

	//调用初始化主动技能和被动技能
	ASC->AddCharacterAbilities(StartupAbilities);
	ASC->AddCharacterPassiveAbilities(StartupPassiveAbilities);
}

编译后,给我们的玩家角色添加上被动技能
在这里插入图片描述
我们在被动技能里debug一下,查看是否能够被正确激活
在这里插入图片描述
如果左上角显示技能激活,证明运行正常
在这里插入图片描述

添加经验发送事件

有了接受,我们接下来要实现发送事件,这个事件我们在AttributeSet里面,在敌人死完时触发,我们增加一个函数专门处理这个


	//发送经验事件
	void SendXPEvent(const FEffectProperties& Props);

然后通过之前我们创建的函数和修改的代码实现

void URPGAttributeSet::SendXPEvent(const FEffectProperties& Props)
{
	if(ICombatInterface* CombatInterface = Cast<ICombatInterface>(Props.TargetCharacter))
	{
		//从战斗接口获取等级和职业,通过蓝图函数获取可提供的经验值
		const int32 TargetLevel = CombatInterface->GetPlayerLevel();
		const ECharacterClass TargetClass = ICombatInterface::Execute_GetCharacterClass(Props.TargetCharacter); //c++内调用BlueprintNativeEvent函数需要这样调用
		const int32 XPReward = URPGAbilitySystemBlueprintLibrary::GetXPRewardForClassAndLevel(Props.TargetCharacter, TargetClass, TargetLevel);

		const FRPGGameplayTags& GameplayTags = FRPGGameplayTags::Get();
		FGameplayEventData Payload; //创建Payload
		Payload.EventTag = GameplayTags.Attributes_Meta_IncomingXP;
		Payload.EventMagnitude = XPReward;
		UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(Props.SourceCharacter, GameplayTags.Attributes_Meta_IncomingXP, Payload);
	}
}

在死亡时调用

在这里插入代码片
接着,我们在里面获取经验属性,来打印经验值debug

if(Data.EvaluatedData.Attribute == GetIncomingXPAttribute())
	{
		UE_LOG(LogRPG, Log, TEXT("获取传入经验值:%f"), GetIncomingXP());
		SetIncomingXP(0);
	}

编译打开编辑器,我们将打印停靠在布局中,用于查看打印结果
在这里插入图片描述
然后运行击杀一只敌人,查看是否有正确的打印消息
在这里插入图片描述

这一篇就到这里,由于篇幅比较长,再开一篇

  • 25
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值