本游戏作为工厂游戏,任务系统的主要功能就是给玩家生产的目标和动力,也就是给玩家发布一个需要一定数量某星尘的订单,玩家提交需要的星尘后会获得奖励,一些任务完成后就能获得随机的事件来给天体上buff,还有诸如在一个随机的合法位置生成一个Actor的事件,游戏内效果如下图:
目录
一、任务的数据结构
先来看一下任务的结构体是怎么定义的:
USTRUCT(BlueprintType)
struct FQuest
{
GENERATED_BODY()
//任务名称
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task")
FString QuestName{ "Empty" };
//任务类型
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task")
ETaskTypes QuestType{ ETaskTypes::Empty };
//任务奖励
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task")
int NovaValueReward{ 10 };
//任务描述
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task")
FText QuestDescription;
//需求星尘
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task/Stardust")
TArray<FStardustBasic>RequiredStardust;
//缺少的星尘
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task/Stardust")
TArray<FStardustBasic>LackedStardust;
//任务当前是否满足完成条件
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task")
bool QuestCanBeCompleted{ 0 };
//子任务
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Task")
TArray<FName>ChildTask;
FQuest() = default;
};
上面的结构中存了任务的名称,同时也是用来查找任务的索引
任务类型枚举决定了该任务完成条件,这里我们只介绍上面提到过的星尘订单这一类任务。
任务奖励在游戏中被称为星爆值,可以理解为玩家累积一定量的星爆值后就会获得上面提到过的随机事件奖励。
任务描述为玩家打开任务后看到的详细描述。
需求的星尘中存储的结构体中包含需求星尘的ID和需求星尘的数量,同时记录了当前要完成这个任务所缺少的星尘。
当缺少的星尘为空时,任务能否完成的bool值被置为真。
每个任务都会有一个或多个子任务,也就是完成该任务后,玩家就会获得该任务的子任务,使任务系统呈现一个树形结构。
二、任务栏
1.任务栏数据结构
玩家可能同时面对多个订单,所以需要一个任务栏容器存储当前玩家的任务,同样有增删查改功能,比库存系统实现起来要简单,就不多赘述了,看一下这些功能怎么定义的:
//返回包含该星尘的任务的索引
UFUNCTION(BlueprintCallable, Category = "QuestBoard")
TArray<int> GetQuestIndexFromStardustId(const FName StardustId)
//添加任务
UFUNCTION(BlueprintCallable,Category="QuestBoard")
bool AddQuest(const FQuest ExpectedQuest);
//删除任务
UFUNCTION(BlueprintCallable, Category = "QuestBoard")
bool RemoveQuest(const int Index);
//通过任务名称查找任务
UFUNCTION(BlueprintCallable, Category = "QuestBoard")
FQuest QueryQuestByName(const FString Name)const;
2.任务进度的更新
我们需要实现的是在玩家背包更新之后,更新所有相关任务的进度,在库存系统的蓝图中有这样一个事件:
该事件会在每个库存变更的函数后面调用,调用后首先会对我们在库存系统中定义的UI更新的动态多播委托进行广播,之后会计算任务进度,其中用到的主要函数就是下面这个计算并更新单个任务进度的函数:
bool UTaskComponent::CalculateLackStardust(UStarInventoryComponent* Inventory, const int Index)
{//计算任务进度,输入玩家背包库存对象,和要计算的任务的索引
if (Index < 0 || Index >= QuestArray.Num())
{//检查索引是否合法
UE_LOG(LogTemp, Error, TEXT("CalculateLackStardust at %d failed,invalid index"), Index);
return false;
}
if (!IsValid(Inventory)
{//检查对象是否有效
UE_LOG(LogTemp, Error, TEXT("CalculateLackStardust failed,invalid pointer:Inventory"));
return false;
}
bool flag = true;//返回该任务是否完成
for (int i = 0; i < QuestArray[Index]->TaskInformation.RequiredStardust.Num(); i++)
{//计算每个需要的星尘缺少的数量
int CurrentLack = std::max(0, QuestArray[Index]->TaskInformation.RequiredStardust[i].Quantity - Inventory->CheckStardust(QuestArray[Index]->TaskInformation.RequiredStardust[i].StardustId));//需要的减去库存中拥有的就是缺少的
QuestArray[Index]->TaskInformation.LackedStardust[i].Quantity = CurrentLack;
if (CurrentLack)
{//只要有一种星尘少了就不能完成任务
flag = false;
}
}
QuestArray[Index]->TaskInformation.QuestCanBeCompleted = flag;//更新任务是否能完成的标记
return flag;
}
三、随机事件奖励
1.随机事件的结构
USTRUCT(BlueprintType)
struct FNovaBurstEventInfo : public FTableRowBase
{
GENERATED_BODY()
// 事件名
UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="NovaBurstEventInfo")
FString EventName{ "Empty" };
// 事件类型
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NovaBurstEventInfo")
ENovaBurstEventType EventType{ ENovaBurstEventType::Empty };
//事件效果
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NovaBurstEventInfo")
ENovaBurstEventEffect Effect{ ENovaBurstEventEffect::Other };
// 事件等级
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NovaBurstEventInfo")
int EventLevel{ 0 };
// 默认数值
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NovaBurstEventInfo")
double DefaultValue{ 1 };
//是否添加到事件随机池里
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NovaBurstEventInfo")
bool Randomable{true};
FNovaBurstEventInfo() = default;
};
该结构也继承自FTableRowBase,所以也可以在数据表格中编辑。
事件名作为查找该事件的索引。
事件有多种类型,这里我们只介绍给天体添加buff的事件
效果枚举是用来决定事件具体该调用哪一个执行函数的
事件的等级决定了它在哪一个事件池中,在随机生成事件时,每个生成事件的等级是固定的,比如我们在玩家第一次星爆时,只会生成3个1级事件
默认数值是该事件的默认数值,表示事件的修正数值,例如加快天体的生产速度的事件,就会使产时间乘以这个默认值
是否可随机表示该事件是否会被添加到随机事件池,因为有一些事件是玩家到达某个阶段固定触发的,这类事件不用随机,这篇日志也不会细讲这类事件
2.随机事件池的初始化
和日志2中加载星尘数据类似,我们同样在游戏实例中加载并保存所有随机事件池中的事件信息:
void UAstromutateGameInstance::LoadPrimeEventDataTable()
{
UDataTable* EventTablePointer = LoadObject<UDataTable>(nullptr, UTF8_TO_TCHAR("DataTable'/Game/Data/NovaBurstEventInformation.NovaBurstEventInformation'"));//加载数据表
PrimeEventPool.Empty();
if (EventTablePointer == nullptr)
{//没找到表格
UE_LOG(LogTemp, Error, TEXT("Can't find PrimeEventInfo"));
return;
}
TArray<FName> RowNames {EventTablePointer->GetRowNames()};//将所有星尘的名字储存进数组里
for (const auto& it : RowNames)
{
FString ContextString;
FNovaBurstEventInfo* Row = EventTablePointer->FindRow<FNovaBurstEventInfo>(it, ContextString);//获取对应名字这一行的信息
if(!Row->Randomable)
continue;
PrimeEventPool.Add(*Row);
}
UE_LOG(LogTemp, Warning, TEXT("PrimeEventMap Loaded %d event"), PrimeEventPool.Num());//输出加载了多少个修正数据
}
3.生成随机事件
这里只展示天体加成的随机事件 ,输入参数为期望获得的事件的等级,例如[1,1,2]表示随机生成两个1级事件和一个2级事件,同时每个随机事件还会搭配生成一个随机的天体
TArray<FEventWithRandomAster> APrime::GetRandomEvent(TArray<int> ExpectedLevels)
{
//这里使用均匀分布的真随机
std::random_device rd;
std::default_random_engine rng {rd()};
//储存不同等级事件的抽奖池
std::unordered_map<int,TArray<FNovaBurstEventInfo>> CurrentEventPool;
//将三个事件的排列顺序打乱
UAstromutateGameInstance::RandomShuffle(ExpectedLevels);
//获取事件池
for(const auto&It:Instance->PrimeEventPool)
{
CurrentEventPool.emplace(It.first,TArray<FNovaBurstEventInfo>());
for(const auto&It2:*It.second)
{
CurrentEventPool[It.first].Add(It2);
}
}
//随机抽取的结果
auto Result{TArray<FEventWithRandomAster>()};
if (!Instance->IsValidLowLevel())
{
UE_LOG(LogTemp, Error, TEXT("GetRandomEvent failed,invalid pointer:Instance"));
return Result;
}
if (ExpectedLevels.IsEmpty())
{
UE_LOG(LogTemp, Error, TEXT("get random event failed, no expected levels,or game ended"));
return Result;
}
for(const auto&It:ExpectedLevels)
{
if(CurrentEventPool[It].IsEmpty())
{
UE_LOG(LogTemp,Error,TEXT("No valid event for level: %d"),It);
continue;
}
std::uniform_int_distribution<int> dist {0, CurrentEventPool[It].Num()-1};
int RandomIndex{dist(rng)};
FName RandomAster{"Empty"};
//对于给特定物体上buff的事件,再随机抽取一个物体
if(CurrentEventPool[It][RandomIndex].EventType==ENovaBurstEventType::UpgradeAster)
RandomAster=GetRandomUnlockedAster();
Result.Add(FEventWithRandomAster(RandomAster,CurrentEventPool[It][RandomIndex]));
//避免抽到重复事件
CurrentEventPool[It].RemoveAt(RandomIndex);
}
return Result;
}
上面用到的洗牌函数,可以确保数组中的每个数等概率的放到每个位置,因为是模板函数,所以对任意类型的数组都可使用:
template<typename T>
static void RandomShuffle(TArray<T>& Array)
{
if(Array.IsEmpty())
return;
std::random_device rd;
std::default_random_engine rng{rd()};
std::uniform_int_distribution<int> dist {0, Array.Num()-1};
//遍历数组的每一个位置
for(int i=0;i<Array.Num();i++)
{
//随机一个位置,将这两个位置交换
int j{dist(rng)};
std::swap(Array[i],Array[j]);
}
}
4.计算随机奖励的概率分布
在我们的游戏中完成终极任务可以抽取超级奖励,不过超级加成和一些证据游戏只能触发一次的特殊事件,那么就要考虑抽奖的概率如何分布来避免玩家连续抽到仅能触发一次的超级事件,同时避免玩家在通关前都没有抽到过仅能触发一次的超级事件。
这里我们设计了一种概率增长算法,即有一个初始概率c,第一抽的独立概率就是c,第二抽的独立概率是2*c,第n抽的独立概率是n*c,直到在某一抽时,概率大于等于1或者到达设定的最大保底次数t,该抽独立概率固定为1,如果有一个这样的概率分布,那我们很容易能算出它的期望,现在我们要根据我们设定的期望和最大保底次数来算出c,显然在t给定的情况下,期望越大,c越大,所以我们可以使用二分法来找到c,代码如下:
double GetProbabilityGrowth(const double& ExpectedProbability,const int&MaxDrawTime)
{
//精度为小数点后六位
double MininumSearchingValue{ 0.000001 };
double MaximumSearchingValue{ 1 };
while (MaximumSearchingValue - MininumSearchingValue >= 1e-6)
{
double SearchingValue{ (MininumSearchingValue + MaximumSearchingValue) / 2 };
double CurrentProbability{ SearchingValue };
int DrawCount{ 0 };
//当前增长率下的期望抽数
double Expectaion{ 0 };
//累积失败概率
double PreviousFailingProbability{ 1 };
while (1)
{
//当前抽数+1
DrawCount++;
//期望+=抽数*该抽独立概率*累计失败概率
Expectaion += DrawCount * CurrentProbability * PreviousFailingProbability;
//维护累计失败概率
PreviousFailingProbability *= (1 - CurrentProbability);
当前概率+=抽数*增长率
CurrentProbability += SearchingValue*(DrawCount+1);
//当前独立概率大于1或到达保底次数时停止,该抽概率为1
if (CurrentProbability - 1 >= 1e-3||DrawCount+1==MaxDrawTime)
{
Expectaion += (DrawCount + 1) * PreviousFailingProbability;
break;
}
}
//将期望抽数转化为期望概率
double CalculatedExpectedProbability{ 1.0 / Expectaion };
//期望概率比预设的大,就减小增长率
if (CalculatedExpectedProbability >= ExpectedProbability)
{
MaximumSearchingValue = SearchingValue;
}
else
{
MininumSearchingValue = SearchingValue;
}
}
return MaximumSearchingValue;
}
5.在一个合理的随机位置生成一个actor
我们作为一个工厂游戏,玩家可以在一个圆环范围内建造工厂,然后这个范围附近会有一些矿点,现在我们需要实现一个在玩家的可建造范围内没有与其他工厂和矿点有重叠的地方生成一个新的矿点的事件。
首先先不考虑生成的位置是否合适,我们需要随机找到一个圆环上的点,那么我们只需要随机一个合适的半径,再随机一个角度,即可将其转换为x,y坐标,代码如下:
FTransform ADebugActor:: GetRandomTransformInRing(const int& MinimumRadius,const int&MaximumRadius)const
{
std::random_device rd;
std::default_random_engine rng {rd()};
//随机生成的范围随着尝试次数而增大
std::uniform_int_distribution dist {MinimumRadius, MaximumRadius+5*NebulasSpawnTriedTimes},dist2{0,628};
FVector Result{FVector(0,0,0)};
//随机半径
int RandomRadius{dist(rng)};
//随机角度
int RandomDegree{dist2(rng)};
RandomDegree/=100;
//转换成x,y坐标
Result.X=RandomRadius*cos(RandomDegree);
Result.Y=RandomRadius*sin(RandomDegree);
//返回的transform
auto ResultTransform{FTransform(Result)};
//设置缩放
ResultTransform.SetScale3D(FVector(0.5,0.5,0.5));
return ResultTransform;
}
因为要生成的Actor是同事用蓝图写的,所以我也只能在蓝图中实现剩余的功能,首先是设置要生成的Actor和那些Actor之间会产生重叠,先在编辑器的项目设置中给每一个种类的建筑建立一个碰撞通道(ObjectChannels):
然后在Actor的碰撞盒的细节面板中,将其设置为对应的通道,并在物体响应中将不希望与其生成时重叠的物体勾上重叠,对应的其他actor也都分别将其设置为对应的通道,上方的生成重叠事件也要勾上,
接着实现Actor检测到重叠时的事件,使其检测到重叠后传送到新的位置,要判断重叠的两个Actor名字是否相同,因为Actor在生成时会触发一次自己和自己的碰撞,设置IsOverlapped变量可以使其在移动到一个合理的位置前避免其他不该触发的事件:
检测重叠事件要在Actor生成3秒后关闭,同时加载存档时生成该Actor也不应该触发重叠事件,避免在不适当的时候转移自己的位置,其中FromSave是生成时公开的变量:
最后就是实现生成Actor的事件,将碰撞处理设置成固定生成,因为我们已经实现了自定义的避免碰撞算法:
关于其他奖励事件如何被执行,与游戏中的其他系统有关,没有太大的参考意义,这里就不过多赘述了
下一篇日志我将会介绍游戏中的存档和基础设置系统是如何实现的