|
一. Action Sequencer的思想及其利弊
所谓ActionSequencer,只是我自己取的一个名字而已,本质上,他可以被认为是一种特殊的解释器模式,是一种简单的受控的脚本语言。有经验的开发者看到这个模式就可以立刻知道他的利弊,接下来,我们将详细的介绍这个工具。
想象这么一个场景,你正在开发一款RPG游戏,里面有数百把剑,每一把剑都有自己的名字,攻击力,射程和装备的限制等级,可能还有有个StaticMesh来指定他的模型。
你可以简单的去写一个表数据结构来描述,写完扔给策划去配表,你就可以去打下一个螺丝了。
#pragma once
#include "Engine/DataTable.h"
#include "WeaponData.generated.h"
USTRUCT(BlueprintType)
struct FWeaponData : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText WeaponName;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float Atk;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float LimitPlayerLevel;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float AttackRange;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
UStaticMesh* WeaponStaticMesh;
// 其他数据......
};
数据表格
OK,现在问题来了,策划跑过来跟你说这么几百把武器只有数值不同,同质化太严重了,现在我们要加点儿东西,我们的游戏每个武器命中之后都会有一个特殊效果,比如某武器击中敌人可以造成恢复自己10点生命。
那么你该如何处理这个问题呢?首先,你可以把你的每个武器都做成蓝图类,让策划过去配蓝图类,用蓝图连连看来解决武器击中后效果的问题,但这样做的问题也很明显:首先,这很不数据驱动,其次,你把一门图灵完全的编程语言交给策划,程序的稳定性和性能都很难保障,会给QA带来笑容,而且QA测一测发现寄了,他还是会跑来找你。
也就是说,你更希望提供给策划一种有限的自由,这个时候你可能就会用到这种工具——Action Sequencer,他的思路非常简单,首先你需要定义一个抽象类Action Node,实现一个类ActionSequencer,他包含了攻击者与受击者战斗组件的引用,还有一个ActionNode数组。
class UActionSequencer;
UCLASS(Abstract, Blueprintable, BlueprintType)
class ADVANCEDLOCOMOTIONSYSTEMV_API UActionNode : public UObject
{
GENERATED_BODY()
public:
TObjectPtr<UActionSequencer> CombinedActionSequencer;
virtual bool Execute();
};
Execute是纯虚函数,之所以这里不写纯虚函数是因为UObject不可以有纯虚函数
接下来,你只需要实现几个Node就可以了,我们以一个需求为例:如果玩家的生命小于50%,那么武器A在命中敌人时造成额外的10点伤害。实现三个Node :
UCLASS(EditInlineNew)
class UPlayerHealthRateLessNode : public UActionNode
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float TargetRate;
// 到.CPP里实现,这里是为了能在网页里展示
virtual bool Execute() override
{
return CombinedActionSequencer->Source->GetHealth() < CombinedActionSequencer->Source->GetMaxHealth * TargetRate;
}
};
UCLASS(EditInlineNew)
class UIfNode : public UActionNode
{
GENERATED_BODY()
public:
UPROPERTY(Instanced, EditAnywhere, BlueprintReadOnly)
TObjectPtr<UActionNode> ConditionNode;
virtual bool Execute() override
{
return ConditionNode->Execute();
}
};
UCLASS(EditInlineNew)
class UApplyDamageNode : public UActionNode
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float DamageValue;
virtual bool Execute() override
{
CombinedActionSequencer->Target->ApplyDamage(DamageValue);
}
};
最后实现一个ActionSequencer
UCLASS(Blueprintable, BlueprintType, EditInlineNew)
class UActionSequencer final : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<UBattleComponent*> Source;
TObjectPtr<UBattleComponent*> Target;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Instanced, DisplayName=&#34;Node序列&#34;)
TArray<TObjectPtr<UActionNode>> ActionNodeSequencer;
// 如果你需要返回值的话,可以改这里的返回值,返回bool或者一个枚举都可以
UFUNCTION(BlueprintCallable)
FORCEINLINE void RunActionSequencer()
{
for (const auto& Node : ActionNodeSequencer)
{
Node->CombinedActionSequencer = this;
if (!Node->Execute())
{
return;
}
}
}
};
对这些代码,最核心的是UPROPERTY里面的Instanced以及UCLASS中的EditInlineNew,如果你是Unity用户,那么如果不使用Odin,想要达成同样的效果就会麻烦一些。
在我还在上大二,完全不懂数据驱动和数据模式的时候,我喜欢称呼这样的行为为“用数据存储逻辑”,那时候我一直在寻找实现一个这种模式的方法。这样的名词听起来云里雾里的,现在我已经不使用这个自创名词了,但是我仍然觉得这个名词把这个行为解释的很贴切。
接下来,实现一个DataAsset就可以了,如果你愿意的话可以写一个DataAsset的编辑器拓展,难度并不大。但注意这里不可以使用DataTable了,因为FTableRowBase,再强大也是F,他没有办法去序列化复杂的UObject。
DataAsset:
UCLASS()
class ADVANCEDLOCOMOTIONSYSTEMV_API UWeaponDataAsset : public UDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText WeaponName;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float Atk;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float LimitPlayerLevel;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
float AttackRange;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
UStaticMesh* WeaponStaticMesh;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Instanced)
TObjectPtr<UActionSequencer> ActionSequencerOnAttackHit;
};
配置武器数据:
注意,Instanced说明符很重要,如果你不写,那你会得到一个这个:
好了!现在你可以给策划用了。
然而,Action Sequencer是一个利弊非常鲜明的设计模式,尽管它看起来很好用,但是他的缺点也很明显。
优点:
1.先数据后编码,可以作为数据存储,批量修改等非常容易。
2.安全,所有的节点内容都受控,你可以放心的交给策划,游戏肯定不会崩溃。
3.简单易用,你甚至可以轻松用这个模式轻松的给你的游戏添加mod支持。
4.灵活,ActionSequencer可以用在几乎任何地方,不需要创建任何资产。它可以以变量的形式出现在任何一个你能想到的Actor或者Object里,比如触发一个陷阱的回调,解耦能力很强。
缺点,他几乎继承了解释器模式所有的缺点:
1.磁盘加载ActionSequencer同时需要实例化并串联成堆的小对象
2.额外的指针内存开销
3.虚函数开销
总结:慢!
开发中,程序可以坐牢,美术可以坐牢,策划可以坐牢,但如果让玩家坐牢,那多多少少就要出问题了。但是,事实上,Action Sequencer的开销也并不会特别高,这主要取决于你的项目与用法:
ActionSequencer中一般包含的节点不会太多,并且,ActionSequencer不同于解释器模式大量使用递归,ActionSequencer并没有很深的堆栈——如果不嵌套使用if节点的话。并且,对于现代游戏而言,你使用ActionSequencer所带来的开销往往可以忽略不计。但最终是要由你的程序员自行确定的。
那么,有没有一种可能,仍然保留这个思想,但是可以让他快起来呢?有,字节码。
二. 字节码
字节码的思想更简单——实现一个你自己的虚拟机,将前几位解释为函数类型,某几位解释为参数,等等...可以在《游戏编程模式》中找到相关介绍,他为项目带来了许多可能——MOD支持,策划也可以用的编辑器等等等等,但是,字节码的缺点也是非常明显的:他的开发难度非常高,并且一定需要开发编辑器,你的调试器会失效,拓展行为不如解释器模式方便等等。
快是有代价的,当且仅当你的项目有以下情况的时候,才需要使用:
1.编程语言太底层了,不好用,比如某++
2.编译时间太长了,比如某++
3.使用者是非程序员背景,对安全敏感,你需要提供安全的沙箱
三. 总结
Action Sequencer提供给了开发者用数据存储逻辑的方法,你可以用它去实现很多游戏开发中需要的gameplay功能,但是,游戏开发中同样没有银弹。Action Sequencer的思想有很多的变体,他们解决了实际应用中遇到的各种问题,但作为程序员,你应该对自己的项目有所了解,选择最适合你的项目的方法,获取你想要的便利的同时,承担你可以承担的风险。 |
|