Unreal Engine 4 书籍翻译 Building an RPG with Unreal (三)_rpjtoh-程序员宅基地

Unreal Engine 4 书籍翻译 Building an RPG with Unreal (三)

好记性不如烂笔头啊,还是记录一下!
自己翻译的书,可能翻译的不好,大家见谅。
欢迎大家指出翻译错误的地方以便修正


第3章 探索和战斗

我们完成一个我们游戏的设计和我们游戏的一个虚幻项目设置,现在是时候编写我们实际的游戏代码了。
在这一章,我们会定义我们的游戏数据来创造一个可以在世界中移动的角色和游戏的基础战斗原型。在这一章我们将进行下列主题:

  • 创建玩家
  • 定义所有类、角色、敌人
  • 持续追踪活动的伙伴成员
  • 创建一个基本的回合制战斗引擎
  • 在屏幕上触发一个游戏

创建角色

我们要做的首要事情是创建一个新的Pawn类。在虚幻引擎中,Pawn是一个角色的表现形式,他是一个可以处理运动,物理和渲染的角色。
现在如何让我们的Pawn角色去工作。一个玩家分为两个部分:
   1.Pawn——他负责处理责处理运动,物理,和渲染。
   2.Player Controller——是负责将玩家的输入进行处理,可以使得Pawn像玩家所想的那样行动。
此外,我们执行一层分离,使Pwan类实现一个名为IControllableCharacter的接口。这不是绝对必要的,但确实有助于防止不同类需要太多了解对方(例如,任何其他的Actor可以实现IControllableCharacter接口,我们的玩家也可以同样的控制那些Actor)


接口

所以首先,我们会研究这个接口。我们从创建一个Actor的派生类开始,正如我们在前一章做。命名为 ControllableCharacter。虚幻引擎生成代码文件后,打开 ControllableCharacter.h,并改变它
为下面的代码︰

  • ControllableCharacter.h

#pragma once
#include "Object.h"
#include "ControllableCharacter.generated.h"

UINTERFACE()
class RPG_API UControllableCharacter : public UInterface
{
    GENERATED_UINTERFACE_BODY()
};

class RPG_API IControllableCharacter
{
    GENERATED_IINTERFACE_BODY()
    virtual void MoveVertical( float Value );
    virtual void MoveHorizontal( float Value );
};

让我们逐句分析下这些代码都做了什么?
在虚幻中,接口有两个部分︰UInterface类和实际类。这两个类,结合虚幻的宏系统,允许您提供接口类转换宏 (这我们稍后将讨论)去转换到Actor实现的接口。

  • UInterface类有U前缀(所以在这种情况下,它是UControllableCharacter)只包含一行GENERATED_UINTERFACE_BODY()
  • 实际的接口类具有I前缀(所以在这种情况下,它是IControllableCharacter),其中有一行GENERATED_IINTERFACE_BODY(),还有实际定义的接口(在这里,我们定义的MoveVertical和MoveHorizontal的方法)。

接下来,打开ControllableCharacter.cpp,将其更改为下面的代码

  • ControllableCharacter.cpp

#include "RPG.h"
#include "ControllableCharacter.h"
UControllableCharacter::UControllableCharacter( const class FObjectInitializer& ObjectInitializer )
  : Super( ObjectInitializer )
{
}

void IControllableCharacter::MoveVertical( float Value )
{
}

void IControllableCharacter::MoveHorizontal( float Value )
{
}

在这里,我们只定义了 UControllableCharacter 类的构造函数和 MoveHorizontal 和 MoveVertical 的默认实现。


PlayerController

接下来,我们要创建PlayerController。PlayerController的作用如前所述,是将玩家输入进行转换,从而实际控制角色的行动。
创建一个新类,选择PlayerController作为基类。它的名字RPGPlayerController。
打开生成的RPGPlayerController.h文件并在类中添加以下代码:

  • RPGPlayerController.h

protected:
    void MoveVertical( float Value );
    void MoveHorizontal( float Value );
    virtual void SetupInputComponent() override;

前两种方法MoveVertical和MoveHorizontal我们已经定义了,是我们将用来侦听玩家输入的两个方法。稍后我们将建立当玩家按下动作键或摇杆时调用这两个方法。
最后一个方法SetupInputComponent,是一个重写的内置方法。顾名思义我们会在这方法中设置输入组件。
接下来,打开RPGPlayerController.cpp并添加以下代码︰

  • RPGPlayerController.cpp

void ARPGPlayerController::MoveVertical( float Value )
{
    IControllableCharacter* pawn = Cast<IControllableCharacter>( GetPawn() );
    if( pawn != NULL )
    {
        pawn->MoveVertical( Value );
    }
}
void ARPGPlayerController::MoveHorizontal( float Value )
{
    IControllableCharacter* pawn = Cast<IControllableCharacter>( GetPawn() );
    if( pawn != NULL )
    {
        pawn->MoveHorizontal( Value );
    }
}
void ARPGPlayerController::SetupInputComponent()
{
    if( InputComponent == NULL )
    {
        InputComponent = ConstructObject<UInputComponent>(UInputComponent::StaticClass(), this, TEXT( "PC_InputComponent0" ) );
        InputComponent->RegisterComponent();
    }
    InputComponent->BindAxis( "MoveVertical", this, &ARPGPlayerController::MoveVertical );
    InputComponent->BindAxis( "MoveHorizontal", this, &ARPGPlayerController::MoveHorizontal );
    this->bShowMouseCursor = true;
}

在这里,我们实现在头文件中定义的方法。让我们来看看它们都做了些什么?
MoveHorizontal和MoveVertical这两个方法是几乎完全相同,所以我们只需要要看看 MoveHorizontal。
首先,我们使用下面的行︰


IControllableCharacter* pawn = Cast<IControllableCharacter>( GetPawn() );

这个方法获取一个PlayerController是当前正在控制中的Pawn指针,然后投射到我们定义Pawn的IControllableCharacter里的接口。
接下来,我们检查指针是否为null,如果不空,我们就调用MoveHorizontal方法来使用A键和D键(如果是MoveVertical则使用W键和S键)控制Pawn,取值范围从-1到1(例如,在使用MoveHorizontal方法时,如果玩家按下A键,则值将会为-1。如果玩家按下D键,则值将会为1。如果玩家不按下任何按键,则值将会为0)
在SetupInputComponent这个方法中,我们首先看看下面的代码:


if( InputComponent == NULL )
{
    InputComponent = ConstructObject<UInputComponent>(UInputComponent::StaticClass(), this, TEXT( "PC_InputComponent0" ) );
}

基本上,如果这里没有附加任何输入控件,我们构造一个新的UInputComponent类的实例(通过ConstructObject宏,我们传入的参数分别是类型,构造的哪个类,这个组件要附加到的Actor,新组件的名称)
现在,我们有了一个输入组件,我们用它绑定我们的运动轴:


InputComponent->BindAxis( "MoveVertical", this, &ARPGPlayerController::MoveVertical );

BindAxis方法设置了一个函数引用一遍在使用输入轴的值时来调用。在前面的代码行,我们调用BindAxis传递的参数为轴的名称,一个指向调用函数Actor的指针,一个Actor用来处理输入的方法的引用
最后,我们设置bShowMouseCursor为true,所以那虚幻不会隐藏鼠标光标。


The Pawn

现在现在,让我们来创建实际的Pawn。
创建一个新类并选择Character作为他的父类。取名为RPGCharacter,打开RPGCharacter.h,并在类定义中更改代码为下面的代码:

  • RPGCharacter.h

UCLASS()
class RPG_API ARPGCharacter : public ACharacter, public IControllableCharacter
{

    GENERATED_BODY()
    ARPGCharacter( const class FObjectInitializer& ObjectInitializer );

public:

    virtual void MoveVertical( float Value );
    virtual void MoveHorizontal( float Value );
};

首先,我们用我们的新类实现IControllableCharacter里的接口。在类中,我们也定义了构造函数、MoveVertical和MoveHorizontal的方法(这是必须要实现的IControllableCharacter的接口)。
接下来,打开RPGCharacter.cpp并添加以下代码︰

  • RPGCharacter.cpp

ARPGCharacter::ARPGCharacter( const class FObjectInitializer &ObjectInitializer )
    : Super( ObjectInitializer )
{
    bUseControllerRotationYaw = false;
    GetCharacterMovement()->bOrientRotationToMovement = true;
    GetCharacterMovement()->RotationRate = FRotator( 0.0f, 0.0f, 540.0f );
    GetCharacterMovement()->MaxWalkSpeed = 400.0f;
}

void ARPGCharacter::MoveVertical( float Value )
{
    if( Controller != NULL && Value != 0.0f )
    {
        const FVector moveDir = FVector( 1, 0, 0 );
        AddMovementInput( moveDir, Value );
    }
}

void ARPGCharacter::MoveHorizontal( float Value )
{
    if( Controller != NULL && Value != 0.0f )
    {
        const FVector moveDir = FVector( 0, 1, 0 );
        AddMovementInput( moveDir, Value );
    }
}

虚幻中的Character有一些内置的运动属性。在构造函数中,我们设置运动组件的一些默认值(默认情况下,Character旋转到面向运动方向的速度为540单位/秒,最大运动速度为400单位/秒)
我们还用构造一个运动向量传递给AddMovementInput,来实现MoveHorizo​​ntal和MoveVertical方法。


游戏模式类

现在,为了使用这些类,我们需要建立了一类新的游戏模式。游戏模式可以指定默认使用的Pwan和PlayerController,我们还可以使用游戏模式的蓝图来修改这些默认的Pawn和PlayerController。
创建一个新类,选择GameMode作为新类的父类。并将类命名为RPGGameMode。
打开RPGGameMode.h并更改类的定义,使用以下代码︰

  • RPGGameMode.h

UCLASS()
class RPG_API ARPGGameMode : public AGameMode
{
    GENERATED_BODY()
    ARPGGameMode( const class FObjectInitializer& ObjectInitializer );
};

正如我们前面已经做过的,我们只需要定义的CPP文件中的构造函数来实现。
我们现在就在RPGGameMode.cpp中实现构造函数:

  • RPGGameMode.cpp

#include "RPGPlayerController.h"
#include "RPGCharacter.h"

ARPGGameMode::ARPGGameMode( const class FObjectInitializer &ObjectInitializer )
    : Super( ObjectInitializer )
{
    PlayerControllerClass = ARPGPlayerController::StaticClass();
    DefaultPawnClass = ARPGCharacter::StaticClass();
}

这里,我们包含RPGPlayerController.h和RPGCharacter.h这两个头文件,这样我们就可以引用这些类。然后,在构造函数中,我们将设置这些类作为默认的PlayerController和Pawn。
现在,如果你编译此代码,你必须去设置你的工程的默认游戏模式为你的新建立的游戏模式。要做到这一点,请到编辑->项目设置,找到默认游戏模式选项框,展开默认游戏模式下拉菜单,并选择RPGGameMode。
然而,我们不一定要直接使用此类。相反,如果我们使用蓝图,我们可以将游戏模式的属性公开的,就可以在蓝图中更新这些公开的属性。
所以,让我们创造一个新的蓝图,命名为DefaultRPGGameMode,让它继承自RPGGameMode:

GameMode

如果你打开这个新的蓝图,导航到默认值选项卡,你可以修改默认的Pawn,HUD,PlayerController以及更多的设置:

GameMode

然而,在我们测试我们新的Pawn和PlayerController之前还有一个额外的步骤。如果你现在运行游戏,你会看不见Pawn。事实上,运行时就像什么都没有发生一样。因为我们需要给我们的Pawn设置一个模型和必须设置一个摄像机跟随我们的Pawn。


添加模型

现在,我们只需要导入第三人称示例中的蓝色角色原型。要做到这一点,请创建一个新的基于第三人称游戏的示例,并将以下内容迁移:

  • HeroTPP
  • HeroTPP_AnimBlueprint
  • HeroTPP_Skeleton
  • IdleRun_TPP

通过以下步骤将这些项目迁移到RPG项目中:

  1. 选中这些资源
  2. 右键单击其中任何一个资源,并选择迁移
  3. 单击确定
  4. 在RPG项目中保存这些资源的文件夹
  5. 单击确定

现在,使用你RPG项目中的HeroTPP模型,让我们为我们的Pawn创建一个新的蓝图。创建一个新的蓝图并选择RPGCharacter作为父类,取名为FieldPlayer。
首先,展开Mesh选项并选择HeroTPP作为Pawn的骨骼。
然后,展开Animation选项并选择HeroTPP_AnimBlueprint作为Pawn的动作。
最后,打开你的游戏模式的蓝图,选择新的FieldPlayer作为你的默认Pawn。
现在,你可以看见你的角色,并且在移动时可以播放一个跑动的动作。
然而,摄像机不会跟随这个角色。我们会通过创建一个自定义的摄像机来解决这个问题。


创建摄像机组件

首先,创建一个新类,选择CameraComponent作为父类,命名为RPGCameraComponent。
RPGCamera
接着,带开RPGCameraComponent.h并在类定义中使用如下代码:

  • RPGCameraComponent.h

UCLASS( meta = ( BlueprintSpawnableComponent ) )
class RPG_API URPGCameraComponent : public UCameraComponent
{
    GENERATED_BODY()

public:

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CameraProperties)
    float CameraPitch;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CameraProperties)
    float CameraDistance;

    virtual void GetCameraView( float DeltaTime, FMinimalViewInfo &DesiredView ) override;
};

让我们看看这一切意味着什么。
首先,在UCLASS宏中,我们添加了这行:


{
UCLASS( meta = ( BlueprintSpawnableComponent ) )    
}

这使得我们可以再Pawn蓝图中添加我们的自定义组件。
接着,我们定义了两个字段,CameraPitch和CameraDistance。

  • CameraPitch控制摄像机视角
  • CameraDistance控制摄像机距离

并将这两个字段添加到CameraProperties这个类别下,这个字段的属性为EditAnywhere和BlueprintReadWrite
最后,我们重写了负责计算摄像机位置,旋转和其他各种属性的GetCameraView函数。当具有此组件的pawn设置为当前视图目标时,虚幻会调用这个函数去定位游戏摄像机。
接下来,打开RPGCameraComponent.cpp并添加以下代码:

  • RPGCameraComponent.cpp

void URPGCameraComponent::GetCameraView( float DeltaTime, FMinimalViewInfo& DesiredView )
{
    UCameraComponent::GetCameraView( DeltaTime, DesiredView );
    DesiredView.Rotation = FRotator( CameraPitch, 0.0f, 0.0f );
    if( APawn* OwningPawn = Cast<APawn>( GetOwner() ) )
    {
        FVector location = OwningPawn->GetActorLocation();
        location -= DesiredView.Rotation.Vector() * CameraDistance;
        DesiredView.Location = location;
    }
}

此函数将覆盖内置的GetCameraView函数。
首先,它调用基类的GetCameraView函数来确保正确的设置了DesiredView。
然后,它从CameraPitch创建了一个FRotator并将其分配给DesiredView的旋转。
最后,它尝试将其所有者转换为APawn。如果OwningPawn不为空,则获取OwningPawn的位置,并减去摄像机的前向向量与CameraDistance的距离。然后将结果分配给DesiredView的位置。
接着,我们需要给我们的Pawn添加摄像机组件,打开你前面章节创建的蓝图Pawn。在Components选项卡中,点击Add Component。当你搜索RPGCamera,你刚才创建的自定义组件会出现在列表中。
在你添加了RPGCameraComponent组件后,滑动你的Details面板直到你看见了Camera Properties属性框。在这里,你可以输入任何你喜欢的值可以改变相机的俯仰程度和距离。但是开始你可以设置为50的俯仰值和600的距离值。
现在,当你运行游戏,摄像机可以在俯视图中跟踪玩家。
现在,我们有了一个可以探索游戏世界的角色,让我们来看看定义角色和伙伴成员。


定义角色和敌人

在上一章节中,我们介绍了如何使用数据表来导入自定义数据。在那之前,我们决定了数据如何在战斗中发挥。现在我们要结合那些来定义我们的游戏的角色,类别和遭遇敌人。


类别

回顾第一章的内容,在虚幻中设计一个RPG,我们设定了我们的角色有一下属性:

  • 生命值
  • 最大生命值
  • 魔法值
  • 最大魔法值
  • 攻击力
  • 防御
  • 幸运

其中,我们可以先不定义生命值和魔法值,因为这两个值会在比赛期间变化。其他值都是角色预定义的基础值,这些就是我们将在数据表中定义的数据。如第一张”RPG入门”所述,我们也需要存储的值是50级(最高等级)。角色将有一些初始能力,还可以在升级时学习一些能力。
我们将在角色类的电子表格中定义这些属性,以及类别的名称。所以我们的角色类表格结构看起来像下面这样:

  • 名称(字符串)
  • 初始最大生命值(整数)(1级时)
  • 最终最大生命值(整数)(50级时)
  • 初始最大魔法值(整数)(1级时)
  • 最终最大魔法值(整数)(50级时)
  • 初始攻击力(整数)(1级时)
  • 最终攻击力(整数)(50级时)
  • 初始防御力(整数)(1级时)
  • 最终防御力(整数)(50级时)
  • 初始幸运值(整数)(1级时)
  • 最终幸运值(整数)(50级时)
  • 初始能力列表(字符串数组)(1级时)
  • 学习能力列表(字符串数组)
  • 学习能力等级(整数数组)

能力字符串数组将包含能力的ID(虚幻中是保留字段)。还有两列来存储学习能力信息——一列为所有可以学习的能力的ID数组,另一列为学习这些能力所需要的等级数组。

在创造游戏的过程中,你应该考虑编写一个自定义工具来帮助管理数据,这样可以减少人为错误。但是,编写类似的工具不属于本书的范围。

现在,我们不应该先为这些属性去创建电子表格,其实我们应该先在Unreal里创建类,再去创建数据表格。原因是,在填写数据时,没有好的文档记载怎么样在数据表的单元格中指定数组。然而在虚幻编辑器中我们可以编辑数组。所以我们简单的创建表格,然后使用虚幻的数组编辑器编辑。

首先,像前面所做的一样,创建一个新类,它从哪里继承不是很重要。所以我们选择Actor类,命名为FCharacterClassInfo。

打开FCharacterClassInfo.h,并使用以下代码替换类的定义:

  • FCharacterClassInfo.h

USTRUCT( BlueprintType )
struct FCharacterClassInfo : public FTableRowBase
{
    GENERATED_USTRUCT_BODY()
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    FString Class_Name;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartMHP;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartMMP;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartATK;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartDEF;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 StartLuck;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndMHP;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndMMP;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndATK;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndDEF;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    int32 EndLuck;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    TArray<FString> StartingAbilities;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    TArray<FString> LearnedAbilities;
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "ClassInfo" )
    TArray<int32> LearnedAbilityLevels;
};

你应该很熟悉大部分的内容,但是最后三个字段你可能不认识。他们都是TArray类型,这是虚幻提供的动态数组类型。从根本上说,TArray和C++的数组不同,他可以动态的添加元素和移除元素

再次编译代码后,你可以通过右键点击Content Browser,然后选择Create Advanced Asset->Miscellaneous->Data Table,来创建一个数据表格。然后,在下拉列表中选择Character Class Info,为你的数据表起个名字,然后双击打开它,你会看到下面的画面:

DataTable

如果Row Editor窗格为空的,你可能需要重启你的虚幻编辑器

要添加新条目,请点击Add按钮。通过向Rename字段里输入文字,然后点击Enter键来给新条目命名。

添加条目后,可以在Data Table窗格中选择该条目,然后在Row Editor穿个对它的属性进行编辑。

我们在列表中添加一个Soldier类。我们将它命名为S1(我们将使用它来引用
来自其他数据表的角色类),它具有以下属性:

  • Class name: Soldier
  • Start MHP: 100
  • Start MMP: 100
  • Start ATK: 5
  • Start DEF: 0
  • Start Luck: 0
  • End MHP: 800
  • End MMP: 500
  • End ATK: 20
  • End DEF: 10
  • End Luck: 10
  • Starting abilities: 现在为空
  • Learned abilities: 现在为空
  • Learned ability levels: 现在为空

角色

让我们来看看角色类的定义,大部分的战斗相关数据已经在character类别里定义了,角色本身会变的非常简单。事实上,现在我们的角色只需要定义两个事情:角色名称和角色引用的类别ID

首先,让我们先来看看角色数据的头文件,FCharacterInfo.h:

  • FCharacterInfo.h

USTRUCT(BlueprintType)
struct FCharacterInfo : public FTableRowBase
{
    GENERATED_USTRUCT_BODY()
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "CharacterInfo" )
    FString Character_Name;
    UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "CharacterInfo" )
    FString Class_ID;
};

和前面做的一样,我们只定义了两个字段(Character_Name和Class_ID)。

在编译后,创建一个数据表格(Data Table)并选择CharacterInfo作为类别,添加一个新的条目取名Character_Name为S1,你也可以取一个你喜欢的名字,但是class
ID必须填写S1(在之前我们定义了名称为S1的小兵类别)


敌人

至于敌人,我们不是单独定义个角色和单独的类别信息。我们会吧两个部分的信息简单的结合起来。作为一个敌人,通常不会处理获得经验和升级,所以我们可以省略这部分相关的数据。除此之外,敌人也不会像玩家一样消耗MP,我们也可以省略与此相关的数据。

因为上面介绍的那些原因,我们的敌人数据会包含以下属性:

  • 名称(整数)
  • 最大生命值(整数)
  • 攻击力(整数)
  • 防御力(整数)
  • 幸运值(整数)

现在,你应该了解如何构造这个类的数据。
先让我们看看这个结构的头文件:

  • FEnemieInfo.h

USTRUCT( BlueprintType )
struct FEnemyInfo : public FTableRowBase
{
    GENERATED_USTRUCT_BODY()
    UPROPERTY( BlueprintReadWrite, EditAnywhere, Category = "EnemyInfo" )
    FString EnemyName;
    UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 MHP;
    UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 ATK;
    UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 DEF;
    UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    int32 Luck;
    UPROPERTY( BlueprintReadOnly, EditAnywhere, Category = "EnemyInfo" )
    TArray<FString> Abilities;
};

在编译之后,创建一个新的数据表格Data Table)并选择EnemyInfo作为类别,添加一个名叫S1的新条目,新条目具有以下属性:

  • Enemy name: Goblin
  • MHP: 100
  • ATK: 5
  • DEF: 0
  • Luck: 0
  • Abilities: 现在为空

现在我们有了一个角色数据,一个角色类别和一个角色可以战斗的敌人。下一步,我们将会开始追踪那些角色是活动的和他们当前统计数据是什么。


伙伴成员

在我们可以追踪伙伴成员前,我们需要一种方法来追踪一个角色的状态,比如说角色还有多少HP,角色穿了什么装备。

要做到这一点,我们需要新创建一个类命名为GameCharacter,像往常一样创建一个新类,但这次需要选择Object作为父类。

此头文件的代码会和以下代码一样:

  • GameCharacter.h

#include "Data/FCharacterInfo.h"
#include "Data/FCharacterClassInfo.h"
#include "GameCharacter.generated.h"

UCLASS( BlueprintType )
class RPG_API UGameCharacter : public UObject
{
    GENERATED_BODY()
public:
    FCharacterClassInfo* ClassInfo;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    FString CharacterName;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 MHP;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 MMP;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 HP;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 MP;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 ATK;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 DEF;

    UPROPERTY( EditAnywhere, BlueprintReadWrite, Category = CharacterInfo )
    int32 LUCK;

public:

    static UGameCharacter* CreateGameCharacter( FCharacterInfo* characterInfo, UObject* outer );

public:

    void BeginDestroy() override;
};

现在我们角色的类信息、角色的名称、角色当前的统计数据。之后我们会用UCLASSUPROPERTY宏去暴露信息给蓝图。在这之后我们会添加一些在战斗系统中用到的信息。

GameCharacter.cpp的代码会像这样:

  • GameCharacter.cpp

UGameCharacter::UGameCharacter( const class FObjectInitializer& objectInitializer )
    :Super( objectInitializer )
{
}

UGameCharacter* UGameCharacter::CreateGameCharacter( FCharacterInfo* characterInfo, UObject* outer )
{
    UGameCharacter* character = NewObject<UGameCharacter>( outer );

    // locate character classes asset 
    UDataTable* characterClasses = Cast<UDataTable>( StaticLoadObject( UDataTable::StaticClass(), NULL, TEXT( "DataTable'/Game/Data/CharacterClasses.CharacterClasses'" ) ) );

    if( characterClasses == NULL )
    {
        UE_LOG( LogTemp, Error, TEXT( "Character classes datatable not found!") );
    }
    else
    {
        character->CharacterName = characterInfo->Character_Name;
        FCharacterClassInfo* row = characterClasses->FindRow<FCharacterClassInfo>( *( characterInfo->Class_ID ), TEXT( "LookupCharacterClass" ) );
        character->ClassInfo = row;
        character->MHP = character->ClassInfo->StartMHP;
        character->MMP = character->ClassInfo->StartMMP;
        character->HP = character->MHP;
        character->MP = character->MMP;
        character->ATK = character->ClassInfo->StartATK;
        character->DEF = character->ClassInfo->StartDEF;
        character->LUCK = character->ClassInfo->StartLuck;
    }
    return character;
}

void UGameCharacter::BeginDestroy()
{
    Super::BeginDestroy();
}

UGameCharacter类的CreateGameCharacter方法接收一个从DataTable返回的指向FCharacterInfo的指针和产生这个Character的对象,用于传递给NewObject函数。然后尝试用一个路径找这个类的DataTable,接着如果结果不为空,则从DataTable中正确读取了一行数据,并被储存。接着用这些读取的数据来初始化Character的统计信息和CharacterName字段。在上面的代码,你可以看到角色DataTable的所在路径,这个路径你可以通过右键点击DataTable,然后选择Copy Reference选项,然后你就可以在你的代码中粘贴路径了。

虽然现在的角色光秃秃的,但是他可以使用。接下来我们要存储这些角色到当前的伙伴列表。


GameInstance类

我们已经创建了一个GameMode(游戏模式)类,这个类看起来是我们追踪和存储伙伴成员的完美地方,是吧?

然而,GameMode(游戏模式)在关卡不同关卡读取时不会保存数据,除非你把这些数据信息存储到了磁盘,每当你到了一个新的区域你将会丢失你的所有数据。

下面我们来介绍一下为了解决这种问题的GameInstance类,font color=#191970 size=3>AGameInstance不像GameMode(游戏模式),不论关卡读取还是做些什么,他一直存在在整个游戏过程中。我们需要创建一个新的GameInstance类来持续追踪和存储伙伴成员的信息。

创建一个新类,这一次我们选择GameInstance作为父类(你需要在查找功能中查找那个类),将它取名为RPGGameInstance。

在这个头文件中,我们需要添加一个用来存储UGameCharacter指针的TArray,一个用来确定游戏已经被初始化的标志位和Init函数:

  • RPGGameInstance.h

UCLASS()
class RPG_API URPGGameInstance : public UGameInstance
{
    GENERATED_BODY()
    URPGGameInstance( const class FObjectInitializer& ObjectInitializer );

public:

    TArray<UGameCharacter*> PartyMembers;

protected:

    bool isInitialized;

public:

    void Init();

};

在游戏实例的Init函数中,我们会添加一个单个默认的伙伴成员并且设置isInitialized标志位为true

  • RPGGameInstance.cpp

void URPGGameInstance::Init()
{
    if( this->isInitialized ) return;
    this->isInitialized = true;

    // locate characters asset
    UDataTable* characters = Cast<UDataTable>( StaticLoadObject( UDataTable::StaticClass(), NULL, TEXT( "DataTable'/Game/Data/Characters.Characters'" ) ) );

    if( characters == NULL )
    {
        UE_LOG( LogTemp, Error, TEXT( "Characters data table not found!" ) );
        return;
    }

    // locate character
    FCharacterInfo* row = characters->FindRow<FCharacterInfo>( TEXT( "S1" ), TEXT( "LookupCharacterClass" ) );

    if( row == NULL )
    {
        UE_LOG( LogTemp, Error, TEXT( "Character ID 'S1' not found!" ) );
        return;
    }

    // add character to party
    this->PartyMembers.Add( UGameCharacter::CreateGameCharacter( row, this ) );
}

在虚幻中设置GameInstance,打开Edit->Project Settings跳转到Maps & Modes,向下滑动到Game Instance窗格,在下拉菜单中选择RPGGameInstance。最后,我们重写GameMode(游戏模式)的BeginPlay函数中调用这个Init方法:

  • RPGGameInstance.cpp

// RPGGameMode.h
virtual void BeginPlay() override;

// RPGGameMode.cpp
void ARPGGameMode::BeginPlay()
{
    Cast<URPGGameInstance>( GetGameInstance() )->Init();
}

现在我们有了一个活动的伙伴成员列表,是时候去实现战斗引擎了。


回合战斗

正如我们第1章“虚幻引擎RPG设计入门”所讲的,我们的战斗是回合制战斗。所有的角色先要选择一个要执行的动作。然后所有角色按照顺序依次执行动作。

战斗会分为两个阶段:

  • 决策,所有角色决定他们的行动方案。
  • 行动,所有角色按照他们的行动方案执行。

我们需要创建一个为我们处理战斗的类,取名为CombatEngine

  • CombatEngine.h

#include "RPG.h"
#include "GameCharacter.h"

enum class CombatPhase : uint8
{
    CPHASE_Decision,
    CPHASE_Action,
    CPHASE_Victory,
    CPHASE_GameOver,
};

class RPG_API CombatEngine
{

public:

    TArray<UGameCharacter*> combatantOrder;
    TArray<UGameCharacter*> playerParty;
    TArray<UGameCharacter*> enemyParty;
    CombatPhase phase;

protected:

    UGameCharacter* currentTickTarget;
    int tickTargetIndex;

public:

    CombatEngine( TArray<UGameCharacter*> playerParty, TArray<UGameCharacter*> enemyParty );
    ~CombatEngine();
    bool Tick( float DeltaSeconds );

protected:

    void SetPhase( CombatPhase phase );
    void SelectNextCharacter();
};

这个类很长,我要一一解释。

我们的战斗引擎会在遭遇敌人时创建并且会在战斗结束时删除。
一个战斗CombatEngine的实例保存着三个TArrays:一个是用来存储战斗顺序(所有战斗参与者的顺序列表,所有参与者会依次轮流行动),另一个是玩家列表,第三个是敌人列表。这个实例也持续追踪着CombatPhase,战斗有两个主要的阶段:DecisionAction,战斗中的每一轮都从Decision阶段开始。在这个阶段,所有的角色决定他们的行动方案。然后战斗转换为Action阶段。在这个阶段中,所有的角色按照顺序执行之前决定的行动方案。

GameOverVictory会在所有敌人全部死亡或者玩家全部死亡时分别转换到这两个状态中。(这就是为什么我们要将敌人列表和玩家列表分成两个单独的列表)

CombatEngine类定义了一个Tick方法,只要战斗没有结束,游戏模式类会每一帧都调用此方法。当战斗结束时,这个方法返回结果为ture(没有结束返回false),这个方法将上一帧的持续时间作为参数。

还有currentTickTargettickTargetIndex,在DecisionAction阶段,我们会保存一个指向单个角色的指针。比如说,在Decision阶段,在开始时指针会指向战斗顺序列表中的第一个角色。在每一帧中,都会有一个函数让这个角色做出决定。如果返回ture表示角色已经完成了决定,如果返回false表示角色还没有决定。如果这个函数返回了true这个指针会指向列表中的下一个角色,然后这样一直持续到所有角色都昨晚了决定。之后战斗转到到Action阶段。

这个CPP文件很大,我们拆分成小块来看。我们先来看看构造函数和析构函数。

  • CombatEngine.cpp

CombatEngine::CombatEngine( TArray<UGameCharacter*> playerParty, TArray<UGameCharacter*> enemyParty )
{
    this->playerParty = playerParty;
    this->enemyParty = enemyParty;
    // first add all players to combat order
    for( int i = 0; i < playerParty.Num(); i++ )
    {
        this->combatantOrder.Add( playerParty[i] );
    }
    // next add all enemies to combat order
    for( int i = 0; i < enemyParty.Num(); i++ )
    {
        this->combatantOrder.Add( enemyParty[i] );
    }
    this->tickTargetIndex = 0;
    this->SetPhase( CombatPhase::CPHASE_Decision );
}

CombatEngine::~CombatEngine()
{

}

构造函数首先分配playerPartyenemyParty这两个字段,然后将所有玩家依次加入到战斗顺序列表中,再将敌人依次加入到战斗顺序列表中。最后,设置目标索引为0(即战斗顺序列表的第一个角色)和战斗阶段为Decision阶段

我们紧接着看看Tick方法:

  • CombatEngine.cpp

bool CombatEngine::Tick( float DeltaSeconds )
{
    switch( phase )
    {
    case CombatPhase::CPHASE_Decision:
        // todo: ask current character to make decision
        // todo: if decision made
        SelectNextCharacter();
        // no next character, switch to action phase
        if( this->tickTargetIndex == -1 )
        {
            this->SetPhase( CombatPhase::CPHASE_Action );
        }
        break;
    case CombatPhase::CPHASE_Action:
        // todo: ask current character to execute decision
        // todo: when action executed
        SelectNextCharacter();
        // no next character, loop back to decision phase
        if( this->tickTargetIndex == -1 )
        {
            this->SetPhase( CombatPhase::CPHASE_Decision );
        }
        break;
    // in case of victory or combat, return true (combat is finished)
    case CombatPhase::CPHASE_GameOver:
    case CombatPhase::CPHASE_Victory:
        return true;
        break;
    }

    // check for game over
    int deadCount = 0;
    for( int i = 0; i < this->playerParty.Num(); i++ )
    {
        if( this->playerParty[ i ]->HP <= 0 ) deadCount++;
    }

    // all players have died, switch to game over phase
    if( deadCount == this->playerParty.Num() )
    {
        this->SetPhase( CombatPhase::CPHASE_GameOver );
        return false;
    }

    // check for victory
    deadCount = 0;
    for( int i = 0; i < this->enemyParty.Num(); i++ )
    {
        if( this->enemyParty[ i ]->HP <= 0 ) deadCount++;
    }

    // all enemies have died, switch to victory phase
    if( deadCount == this->enemyParty.Num() )
    {
        this->SetPhase( CombatPhase::CPHASE_Victory );
        return false;
    }

    // if execution reaches here, combat has not finished - return false
    return false;
}

我们先看当前值阶段是处于哪个战斗阶段,如果处于Decision阶段我们只做了选择下一个角色这件事,如果没有角色可以选择了,则切换到Action阶段。如果处于Action阶段也是同样的逻辑,如果没有角色可以选择了,则循环切换回Decision阶段

之后会调用角色的方法使得他们按顺序做决定或者执行动作。(注意:选择一下一个角色这个函数只能在完成决定后或者执行动作后调用一次。)

GameOverVictory阶段,Tick返回true意味着战斗结束了。
在战斗没有结束时,函数先检查是不是所有玩家都死亡了(检查战斗是不是失败了),然后检查了所有敌人是不是死亡了(检查战斗是不是胜利了)。这个两个阶段都会返回true表示战斗结束了。

在函数的最后返回了false来表示战斗还没有结束。

接下来我们来看看SetPhase函数:

  • CombatEngine.cpp

void CombatEngine::SetPhase( CombatPhase phase )
{
    this->phase = phase;
    switch( phase )
    {
    case CombatPhase::CPHASE_Action:
    case CombatPhase::CPHASE_Decision:
        // set the active target to the first character in the combat order
        this->tickTargetIndex = 0;
        this->SelectNextCharacter();
        break;
    case CombatPhase::CPHASE_Victory:
        // todo: handle victory
        break;
    case CombatPhase::CPHASE_GameOver:
        // todo: handle game over
        break;
    }
}

这是个设置战斗阶段的函数,当战斗阶段为Action后者Decision时,这个函数会设置tickTargetIndex为战斗顺序列表中的第一个。VictoryGameOver预留着各自的状态处理。

最后我们来看SelectNextCharacter

  • CombatEngine.cpp

void CombatEngine::SelectNextCharacter()
{
    for( int i = this->tickTargetIndex; i < this->combatantOrder.Num(); i++ )
    {
        GameCharacter* character = this->combatantOrder[ i ];
        if( character->HP > 0 )
        {
            this->tickTargetIndex = i + 1;
            this->currentTickTarget = character;
            return;
        }
    }
    this->tickTargetIndex = -1;
    this->currentTickTarget = nullptr;
}

这个函数从当前tickTargetIndex位置开始按顺序向后找到一个没有死亡的角色。如果找到一个,就将tickTargetIndexcurrentTickTarget都设置为这个角色。如果没有找到,就将tickTargetIndex设置为-1,currentTickTarget设置为空指针(这意味着作战顺序列表里面已经没有存活的角色了)。

现在还遗漏了一件非常重要的事情:角色还不能作出或者执行决定。

让我们将这两个方法加入到GameCharacter类中,只是作为预留的方法。

首先我们添加testDelayTimer字段,这个字段只作为测试用途。

  • GameCharacter.h
protected:
    float testDelayTimer;

接下来我们往类中添加几个方法。

  • GameCharacter.h
public:
    void BeginMakeDecision();
    bool MakeDecision( float DeltaSeconds );

    void BeginExecuteAction();
    bool ExecuteAction( float DeltaSeconds );

我们以同样的方式分离了DecisionAction,让他们各自拥有两个函数。第一个函数是告诉角色开始做决定或者开始执行动作,第二个函数的本质上是一直查询角色是否已经完成决定或者完成执行动作。

这两个方法我们会在以后实现,现在,我们只是延迟一秒输出日志:

  • GameCharacter.cpp
void UGameCharacter::BeginMakeDecision()
{
    UE_LOG( LogTemp, Log, TEXT( "Character %s making decision" ), *this->CharacterName );
    this->testDelayTimer = 1;
}

bool UGameCharacter::MakeDecision( float DeltaSeconds )
{
    this->testDelayTimer -= DeltaSeconds;
    return this->testDelayTimer <= 0;
}
void UGameCharacter::BeginExecuteAction()
{
    UE_LOG( LogTemp, Log, TEXT( "Character %s executing action" ), *this->CharacterName );
    this->testDelayTimer = 1;
}

bool UGameCharacter::ExecuteAction( float DeltaSeconds )
{
    this->testDelayTimer -= DeltaSeconds;
    return this->testDelayTimer <= 0;
}

我们还要添加一个指向战斗实例的指针。因为战斗引擎已经引用了角色类,角色类在引用战斗引擎会产生循环依赖。为了避免这个问题,我们需要在GameCharacter.h中添加前置声明。

  • GameCharacter.h
class CombatEngine;

然后,战斗引擎的include语句应该放在
GameCharacter.cpp中而不是在头文件中。

接下来,我们要用战斗引擎来调用DecisionAction的方法,我们要先在CombatEngine类中添加一个标志位:

  • CombatEngine.h
bool waitingForCharacter;

这个标志位将用于切换。例如,在BeginMakeDecisionMakeDecision之前切换。

接下来,我们要更新Tick方法中的DecisionAction阶段。我们先来更新一下前面的Decision部分。

  • CombatEngine.cpp
{
    if( !this->waitingForCharacter )
    {
        this->currentTickTarget->BeginMakeDecision();
        this->waitingForCharacter = true;
    }

    bool decisionMade = this->currentTickTarget->MakeDecision( DeltaSeconds );
    if( decisionMade )
    {
        SelectNextCharacter();
        // no next character, switch to action phase
        if( this->tickTargetIndex == -1 )
        {
            this->SetPhase( CombatPhase::CPHASE_Action );
        }
    }
} 
break;

如果waitingForCharacterfalse,它会调用BeginMakeDecision方法并且设置waitingForCharactertrue

注意整个括号括起来的case语句,如果你不加这个括号,case语句会在编译时报decisionMade初始化被跳过的错误。

接着调用了MakeDecision方法并传递了一帧的时间作为参数。如果这个方法返回true,将会选择下一个角色。返回false就切换到Action阶段。

Action阶段和上面的代码几乎相同:

  • CombatEngine.cpp
{
    if( !this->waitingForCharacter )
    {
        this->currentTickTarget->BeginExecuteAction();
        this->waitingForCharacter = true;
    }
    bool actionFinished = this->currentTickTarget->ExecuteAction( DeltaSeconds );
    if( actionFinished )
    {
        SelectNextCharacter();
        // no next character, switch to action phase
        if( this->tickTargetIndex == -1 )
        {
            this->SetPhase( CombatPhase::CPHASE_Decision );
        }
    }
}
break;

接着我们要更新一下SelectNextCharacter方法,需要在这个方法中将waitingForCharacter设置回false

  • CombatEngine.cpp
void CombatEngine::SelectNextCharacter()
{
    this->waitingForCharacter = false;
    // ...(原先代码)
}

最后,我们还要完善一些细节:我们的战斗引擎需要设置所有的角色的CombatInstance的指针指向自己,我们需要在构造函数里做这些。然后我们还需要在析构函数中清空这些指针和敌人的指针:

  • CombatEngine.cpp
CombatEngine::CombatEngine( TArray<UGameCharacter*> playerParty, TArray<UGameCharacter*> enemyParty )
{
    // ...
    for( int i = 0; i < this->combatantOrder.Num(); i++ )
    {
        this->combatantOrder[i]->combatInstance = this;
    }
    this->tickTargetIndex = 0;
    this->SetPhase( CombatPhase::CPHASE_Decision );
}

CombatEngine::~CombatEngine()
{
    // free enemies
    for( int i = 0; i < this->enemyParty.Num(); i++ )
    {
        this->enemyParty[i] = nullptr;
    }

    for( int i = 0; i < this->combatantOrder.Num(); i++ )
    {
        this->combatantOrder[i]->combatInstance = nullptr;
    }
}

现在战斗引擎到功能已经完整了,我们还需要把它挂钩到游戏中。我们要在游戏模式中去触发战斗和更新战斗。

所以在我们的游戏模式类中,我们需要添加个指针去指向当前战斗。然后重写游戏模式类的Tick方法。此外还的保存一个追踪角色的列表(修饰符要用UPROPERTY,这样敌人就可以被垃圾回收了):

  • RPGGameMode.h
UCLASS()
class RPG_API ARPGGameMode : public AGameMode
{
    GENERATED_BODY()

    ARPGGameMode( const class FObjectInitializer& ObjectInitializer );
    virtual void Tick( float DeltaTime ) override;

public:

    CombatEngine* currentCombatInstance;
    TArray<UGameCharacter*> enemyParty;
};

接着在cpp文件中我们来实现它的Tick方法:

  • RPGGameMode.cpp
void ARPGGameMode::Tick( float DeltaTime )
{
    if( this->currentCombatInstance != nullptr )
    {
        bool combatOver = this->currentCombatInstance->Tick( DeltaTime );
        if( combatOver )
        {
            if( this->currentCombatInstance->phase == CombatPhase::CPHASE_GameOver )
            {
                UE_LOG( LogTemp, Log, TEXT( "Player loses combat, game over" ) );
            }
            else if( this->currentCombatInstance->phase == CombatPhase::CPHASE_Victory )
            {
                UE_LOG( LogTemp, Log, TEXT( "Player wins combat" ) );
            }
            // enable player actor
            UGameplayStatics::GetPlayerController( GetWorld(), 0 )->SetActorTickEnabled( true );

            delete( this->currentCombatInstance );
            this->currentCombatInstance = nullptr;
            this->enemyParty.Empty();
        }
    }
}

我们现在只检查是否有当前战斗实例。如果有,则调用战斗实例的Tick方法。如果返回true,则检查状态是胜利了还是失败了。(现在我们只是输出了日志在控制台)。然后,删除了了战斗实例,设置当前战斗实例为空,然后清空了敌方的角色列表(因为列表有UPROPERTY修饰符,会使列表内的敌人自动被垃圾回收),在这我们还启用了玩家的Tick方法。(我们会在战斗开始时禁用玩家的Tick方法,所以玩家会在战斗时冻结在原地)

我们也已经准备好遭遇敌人了,但是现在没有敌人和我们战斗。

我们已经定义了敌人信息的表,但是我们的GameCharacter类还不支持用EnemyInfo来初始化敌人(前面我们只实现了初始化玩家)。

为了解决这个问题,我们需要在GameCharacter类中创建一个工厂方法(确定你也在头部添加了EnemyInfo类的include语句):

  • GameCharacter.h
static UGameCharacter* CreateGameCharacter( FEnemyInfo* enemyInfo, UObject* outer );

我们也得实现这个重载方法:

  • GameCharacter.cpp
UGameCharacter* UGameCharacter::CreateGameCharacter( FEnemyInfo* enemyInfo, UObject* outer )
{
    UGameCharacter* character = NewObject<UGameCharacter>( outer );
    character->CharacterName = enemyInfo->EnemyName;
    character->ClassInfo = nullptr;
    character->MHP = enemyInfo->MHP;
    character->MMP = 0;
    character->HP = enemyInfo->MHP;
    character->MP = 0;
    character->ATK = enemyInfo->ATK;
    character->DEF = enemyInfo->DEF;
    character->LUCK = enemyInfo->Luck;
    return character;
}

这是一个比较简单的实现,简单分配了名称,ClassInfo为空(因为敌人并没有与他们关联的类)和其他的统计数据(MMPMP都设置为0,因为敌人不用消耗MP)。

为了测试我们的战斗系统,我们在RPGGameMode.h中创建了一个函数,这个函数可以在虚幻控制台调用。

  • RPGGameMode.h
UFUNCTION(exec)
void TestCombat();

UFUNCTION(exec)宏可以让这个函数可以在虚幻控制台中使用命令调用。

RPGGameMode.cpp中此方法的实现如下:

  • RPGGameMode.cpp
void ARPGGameMode::TestCombat()
{
    // locate enemies asset
    UDataTable* enemyTable = Cast<UDataTable>( StaticLoadObject
        ( UDataTable::StaticClass()
        , NULL
        , TEXT( "DataTable'/Game/Data/Enemies.Enemies'" ) 
        ) );

    if( enemyTable == NULL )
    {
        UE_LOG( LogTemp, Error, TEXT( "Enemies data table not found!" ) );
        return;
    }

    // locate enemy
    FEnemyInfo* row = enemyTable->FindRow<FEnemyInfo>( TEXT( "S1" ), TEXT( "LookupEnemyInfo" ) );

    if( row == NULL )
    {
        UE_LOG( LogTemp, Error, TEXT( "Enemy ID 'S1' not found!" ) );
        return;
    }

    // disable player actor
    UGameplayStatics::GetPlayerController( GetWorld(), 0 )->SetActorTickEnabled( false );

    // add character to enemy party
    UGameCharacter* enemy = UGameCharacter::CreateGameCharacter( row, this );
    this->enemyParty.Add( enemy );

    URPGGameInstance* gameInstance = Cast<URPGGameInstance>( GetGameInstance() );

    this->currentCombatInstance = new CombatEngine( gameInstance->PartyMembers, this->enemyParty );

    UE_LOG( LogTemp, Log, TEXT( "Combat started" ) );
}

在这我们创建了一个敌人的DataTable,并选择了ID为S1的敌人创造了一个GameCharacter,紧接着创造了一个敌人的列表来添加这些敌人。然后创建了一个CombatEngine的实例传递给了玩家方,敌人的列表传给了敌方。我们还必须在战斗开始的时候禁用Tick方法,来停止对玩家的更新。

最后,我们必须测试一下战斗引擎,开始游戏后按键盘的(~)键来调出控制台命令行窗口,输入TestCombat然后按Enter键。

在输出窗口,你可以看到一些和下面信息类似的信息:

LogTemp: Combat started
LogTemp: Character Kumo making decision
LogTemp: Character Goblin making decision
LogTemp: Character Kumo executing action
LogTemp: Character Goblin executing action
LogTemp: Character Kumo making decision
LogTemp: Character Goblin making decision
LogTemp: Character Kumo executing action
LogTemp: Character Goblin executing action
LogTemp: Character Kumo making decision
LogTemp: Character Goblin making decision
LogTemp: Character Kumo executing action
LogTemp: Character Goblin executing action
LogTemp: Character Kumo making decision

首先这些信息表明战斗引擎正在像预期一样的运行。所有的角色都做出一个决定,然后去执行决定。接着他们又会做出决定,然后继续去执行决定,然后一直持续下去。因为没有人做任何事情(更不会造成任何伤害),所以战斗会一直持续下去。

现在还有两个问题围绕着我们:第一,就是前面提到的问题,没有一个角色真正的做任何事情。此外,玩家角色需要一个与敌人不同的方式来作出决定(玩家角色需要一个UI去选择动作来作出决定,相反敌人角色需要自动的作出决定)

我们会在解决决策问题之前先解决第一个问题。


执行动作

为了能让角色执行动作,我们要把所有的战斗动作归为一个通用的接口。我们现在已经有了映射这些接口的好地方。那就是角色的BeginExecuteActionExecuteAction这两个方法。

让我们像下面一样创建一个新的接口ICombatAction

  • CombatAction.h
#pragma once
#include "GameCharacter.h"

class UGameCharacter;

class ICombatAction
{ 
public:

    virtual void BeginExecuteAction( UGameCharacter* character ) = 0;
    virtual bool ExecuteAction( float DeltaSeconds ) = 0;
};

BeginExecuteAction接收一个指向正在执行动作的角色的指针
ExecuteAction像之前一样,接收上一帧的时间作为参数

接着我们创建一个新的动作类来实现这些接口。作为测试,我们在新类TestCombatAction中复制前面角色已经做的功能(也就是什么都没有,打印些日志):

头文件的代码会是下面这样:

  • TestCombatAction.h
#pragma once

#include "ICombatAction.h"

class TestCombatAction : public ICombatAction
{ 
protected:

    float delayTimer;
public:

    virtual void BeginExecuteAction( UGameCharacter* character ) override;
    virtual bool ExecuteAction( float DeltaSeconds ) override;
};

cpp代码会是下面这样:

  • TestCombatAction.cpp
#include "RPG.h"
#include "TestCombatAction.h"

void TestCombatAction::BeginExecuteAction( UGameCharacter* character )
{
    UE_LOG( LogTemp, Log, TEXT( "%s does nothing" ), *character->CharacterName );
    this->delayTimer = 1.0f;
}

bool TestCombatAction::ExecuteAction( float DeltaSeconds )
{
    this->delayTimer -= DeltaSeconds;
    return this->delayTimer <= 0.0f;
}

接着,我们要修改角色类能够存储和执行这些动作。

首先,将角色类中测试用的delayTimer字段替换成一个战斗动作的指针。然后在我们需要在创建决策系统时公开这个字段。

  • GameCharacter.h
public:
    ICombatAction* combatAction;

接着我们需要在决策函数中分配一个战斗动作,在执行函数中执行这个动作:

  • GameCharacter.cpp
void UGameCharacter::BeginMakeDecision()
{
    UE_LOG( LogTemp, Log, TEXT( "Character %s making decision" ), *( this->CharacterName ) );
    this->combatAction = new TestCombatAction();
}

bool UGameCharacter::MakeDecision( float DeltaSeconds )
{
    return true;
}

void UGameCharacter::BeginExecuteAction()
{
    this->combatAction->BeginExecuteAction( this );
}

bool UGameCharacter::ExecuteAction( float DeltaSeconds )
{
    bool finishedAction = this->combatAction->ExecuteAction( DeltaSeconds );
    if( finishedAction )
    {
        delete( this->combatAction );
        return true;
    }
    return false;
}

BeginMakeDecision现在分配了一个TestCombatAction的实例,MakeDecision只是返回了trueBeginExecuteAction方法,用存储的动作调用了相同名称的方法,并且传递了这个角色的指针作为参数。最后,ExecuteAction函数,也使用存储的动作调用了同名的方法并且得到了个结果,如果结果是true则删除指针并且返回true,相反则返回false

让我们再次测试一下新的代码,你会发现在输出窗口会输出同样的日志信息,但现在它的作用是说明做什么而不是怎么做。

现在我们已经有个方法来存储和执行动作了,接着我们要来实现我们的角色决策系统了。


决策

我们会像之前做执行动作一样,为决策系统重新创建一个接口,类似于BeginMakeDecision/MakeDecision这样的模式。IDecisionMaker会像下面这样:

  • DecisionMaker.h
#pragma once

#include "GameCharacter.h"

class UGameCharacter;

class IDecisionMaker
{ 
public:

    virtual void BeginMakeDecision( UGameCharacter* character ) = 0;
    virtual bool MakeDecision( float DeltaSeconds ) = 0;
};

然后,我们要创建TestDecisionMaker来实现接口:

  • TestDecisionMaker.h
// TestDecisionMaker.h
#pragma once

#include "IDecisionMaker.h"

class RPG_API TestDecisionMaker : public IDecisionMaker
{
public:

    virtual void BeginMakeDecision( UGameCharacter* character ) override;
    virtual bool MakeDecision( float DeltaSeconds ) override;
};
  • TestDecisionMaker.cpp

// TestDecisionMaker.CPP

#include "RPG.h"
#include "TestDecisionMaker.h"
#include "../Actions/TestCombatAction.h"

void TestDecisionMaker::BeginMakeDecision( UGameCharacter* character )
{
    character->combatAction = new TestCombatAction();
}

bool TestDecisionMaker::MakeDecision( float DeltaSeconds )
{
    return true;
}

接着我们要往角色类里面添加一个指向IDecisionMaker的指针,并且修改BeginMakeDecision/MakeDecision方法来使用决策类。

  • GameCharacter.h
// GameCharacter.h
public:
    IDecisionMaker* decisionMaker;
  • GameCharacter.cpp
// GameCharacter.cpp
void UGameCharacter::BeginDestroy()
{
    Super::BeginDestroy();
    delete( this->decisionMaker );
}

void UGameCharacter::BeginMakeDecision()
{
    this->decisionMaker->BeginMakeDecision( this );
}

bool UGameCharacter::MakeDecision( float DeltaSeconds )
{
    return this->decisionMaker->MakeDecision( DeltaSeconds );
}

现在我们在BeginDestroy函数中删除决策类对象吗,决策类对象会在角色创建时分配,并且他们被摧毁前删除这个对象。

最后一步当然是在构造函数中分配决策类对象,在所有角色类的构造函数中添加以下代码:

  • GameCharacter.cpp
// GameCharacter.cpp
    this->decisionMaker = new TestDecisionMaker();

重新运行游戏,再次测试战斗,你可以在输出窗口看到完全一样的输出。然而,有个很大的区别,现在可以实现不同的角色被分配不同的决策,并且选择决策可以方便的去分配战斗动作去执行。例如,现在我们很容易去测试一个对目标造成伤害的动作。但是在这之前,我们先对GameCharacter类做一些小小的改动。


目标选择

我们需要在GameCharacter类中添加个字段来标识这个是个角色、还是玩家、还是敌人。另外我们还要添加一个SelectTarget方法用来从当前战斗实例中的玩家列表或者敌人列表中,选择第一个存活的角色,怎么选择是取决去这个角色是玩家还是敌人。

我们先在GameCharacter.h中添加一个isPlayer字段:

  • GameCharacter.h
    bool isPlayer;

紧接着我们还要添加一个SelectTarget方法

  • GameCharacter.h
    UGameCharacter* SelectTarget();

GameCharacter.cpp文件中我们需要在创建角色的函数中给这个字段赋值。(这是很简单的,因为我们的玩家和敌人拥有独立的创建函数)

  • GameCharacter.cpp
UGameCharacter* CreateGameCharacter( FCharacterInfo* characterInfo, UObject* outer )
{
    //...(原有代码)
    character->isPlayer = true;
    return character;
}

UGameCharacter* CreateGameCharacter( FEnemyInfo* enemyInfo, UObject* outer )
{
    // ...(原有代码)
    character->isPlayer = false;
    return character;
}

接着我们需要定义SelectTarget方法:

  • GameCharacter.cpp
UGameCharacter* UGameCharacter::SelectTarget()
{
    UGameCharacter* target = nullptr;

    TArray<UGameCharacter*> targetList = this->combatInstance->enemyParty;
    if( !this->isPlayer )
    {
        targetList = this->combatInstance->playerParty;
    }

    for( int i = 0; i < targetList.Num(); i++ )
    {
        if( targetList[ i ]->HP > 0 )
        {
            target = targetList[i];
            break;
        }
    }

    if( target->HP <= 0 )
    {
        return nullptr;
    }
    return target;
}

首先计算出我们需要在哪个列表(玩家列表和敌人列表)中选择我们的目标,然后遍历列表去寻找一个没有死亡的目标。如果没有找到,这个函数返回一个空指针。


造成伤害

现在有了一个简单选择目标的方式,让我们修改TestCombatAction类,使这个类用这个简单的方式来选择目标,然后我们尝试对目标造成伤害。

我们先添加两个字段来维护对角色和目标的引用并且让我们的构造函数接收一个GameCharacter目标作为参数:

  • TestCombatAction.h
protected:
    UGameCharacter* character;
    UGameCharacter* target;
public:
    TestCombatAction( UGameCharacter* target );

下面是实现的代码:

  • TestCombatAction.cpp
TestCombatAction::TestCombatAction( UGameCharacter* target )
{
    this->target = target;
}

void TestCombatAction::BeginExecuteAction( UGameCharacter* character )
{
    this->character = character;

    // target is dead, select another target
    if( this->target->HP <= 0 )
    {
        this->target = this->character->SelectTarget();
    }

    // no target, just return
    if( this->target == nullptr )
    {
        return;
    }

    UE_LOG( LogTemp, Log, TEXT( "%s attacks %s" ), *character->CharacterName, *target->CharacterName );

    target->HP -= 10;
    this->delayTimer = 1.0f;
}

首先在构造函数中对target进行赋值。然后在BeginExecuteAction方法中,先对character进行赋值,紧接着检查目标是否存活。如果目标已经死亡,就会调用我们刚刚创建的SelectTarget方法来获取一个新目标。如果获得的新目标也为空,这意味着函数返回为空,也就是说没有可用的目标了。相反如果找到了新目标,将会输出一条格式为[character] attacks [target]的日志,最后扣除目标一部分HP,然后设置delayTimer

下一步就是修改我们的TestDecisionMaker去选择一个目标并且将这个目标传给TestCombatAction的构造函数,这是一个比较简单的修改:

  • TestDecisionMaker.cpp
void TestDecisionMaker::BeginMakeDecision( UGameCharacter* character )
{
    // pick a target
    UGameCharacter* target = character->SelectTarget();
    character->combatAction = new TestCombatAction( target );
}

现在你可以运行游戏,测试一次遭遇战斗,你会在输出窗口看到类似下面的信息:

LogTemp: Combat started
LogTemp: Kumo attacks Goblin
LogTemp: Goblin attacks Kumo
LogTemp: Kumo attacks Goblin
LogTemp: Player wins combat

现在,我们有了一个两方可以互相攻击,并且有一方会获胜的战斗系统

下一步,我们要将这些与用户界面连接


用UMG制作战斗UI

首先,我们需要设置我们的工程以确保正确的引入了UMGSlate相关类。

打开RPG.Build.cs(也就是[ProjectName].Build.cs)并且找到下面这行并修改成这样:

  • [ProjectName].Build.cs
PublicDependencyModuleNames.AddRange( 
    new string[] { 
        "Core", 
        "CoreUObject",
        "Engine", 
        "InputCore", 
        "UMG", 
        "Slate", 
        "SlateCore" 
    } 
);

这句语句的意思是,将UMGSlateSlateCore添加到现有字符串数组。

接着,打开RPG.h然后加入下面这几行代码:

  • RPG.h
#include "Runtime/UMG/Public/UMG.h"
#include "Runtime/UMG/Public/UMGStyle.h"
#include "Runtime/UMG/Public/Slate/SObjectWidget.h"
#include "Runtime/UMG/Public/IUMGModule.h"
#include "Runtime/UMG/Public/Blueprint/UserWidget.h"

现在编译这个工程,这会需要一点时间。

接着,我们创建一个战斗UI的基类。基本上,我们使用这个基类通过定义Blueprint-implementable在函数头部来允许C++游戏代码与蓝图UMG代码通信,这个函数可以在蓝图里实现并用C++调用

创建一个新类命名为CombatUIWidget并且选择UserWidget作为父类:

  • CombatUIWidget.h

#include "GameCharacter.h"
#include "Blueprint/UserWidget.h"
#include "CombatUIWidget.generated.h"

UCLASS()
class RPG_API UCombatUIWidget : public UUserWidget
{
    GENERATED_BODY()

public:

    UFUNCTION( BlueprintImplementableEvent, Category = "Combat UI" )
    void AddPlayerCharacterPanel( UGameCharacter* target );

    UFUNCTION( BlueprintImplementableEvent, Category = "Combat UI" )
    void AddEnemyCharacterPanel( UGameCharacter* target );
};

大多数情况下,我们只会定义几个个函数。AddPlayerCharacterPanelAddEnemyCharacterPanel函数接收一个指向角色的指针和生成一个该角色的窗口控件。(用来显示当前角色的统计数据)。

然后我们编译下代码,完成后返回编辑器,创建一个新的Widget Blueprint命名为CombatUI,创建完成后打开它。选择File->Reparent Blueprint并且选择CombatUIWidget作为父类。

Designer界面中,创建两个Horizontal Box窗口控件并分别命名为enemyPartyStatusplayerPartyStatus,他们将会分别拥有很多玩家和敌人的子控件,去显示他们的角色统计数据。对于他们,一定要启用Is Variable选项框,他们就对蓝图来说是可用的变量,保存并编译蓝图。

然后,我们要为玩家和敌人创建显示角色统计数据的控件,我们先要创建一个需要被其他空间继承的基础控件。

创建一个新的Widget Blueprint命名为BaseCharacterCombatPanel,在这个蓝图中,添加一个新变量CharacterTarget并选择Game Character作为Object Reference类别。

然后,我们要为玩家和敌人做各自的控件。

创建一个新的Widget Blueprint命名为PlayerCharacterCombatPanel,设置新蓝图的父类为BaseCharacterCombatPanel

Designer界面中,添加三个Text Block控件,一个为角色名称,另一个为角色HP,第三个为角色MP。我们通过在Details面板选择控件并且点击Bind,弹出的旁边的文本,来创建一个绑定:

Create Binding

这个操作将创建一个新的蓝图函数来负责生成文本。

例如,想要绑定HP文本,你需要下列步骤:

  1. 拖拽Character Target变量到视图中,并且选择Get
  2. 拖拽这个节点的引脚并且在Variables->Character Info下选择Get HP
  3. 创建一个新的Format Text节点,设置Format字段为HP: {HP},然后连接Get HP的输出到Format Text节点的HP字段的输入。
  4. 连接Format Text节点的输出到Return节点的Return Value

你可以重复以上步骤来创建角色名称和MP的文本。

在你完成了PlayerCharacterCombatPanel之后,你可以用同样的步骤来创建EnemyCharacterCombatPanel,除了不要创建MP的文本块(就像前面讲的,敌人并不用消耗MP)。

最终MP的展现视图的画面会像下面这样:

MP文本快

现在我们有了玩家和敌人的控件,让我们在CombatUI蓝图中实现AddPlayerCharacterPanelAddEnemyCharacterPanel函数。

我们要先创建一个帮助函数来创建角色统计数据控件,函数命名为SpawnCharacterWidget并且加入下列输入参数:

  • Target Character(游戏角色引用类型)
  • Target Panel(面板控件引用类型)
  • Class(基础战斗角色面板类)

这个函数需要执行下列步骤:

  1. 为传入的Class创建一个新控件
  2. 转换这个新控件为BaseCharacterCombatPanel类型
  3. 设置Character Target为输入的TargetCharacter
  4. 把这个新控件作为TargetPanel的子控件。

蓝图最终会像下面这样:

SpawnCharacterWidget

然后,在CombatUI蓝图的事件视图中,右键点击添加EventAddPlayerCharacterPanelEventAddEnemyCharacterPanel事件,将他们各自挂钩一个SpawnCharacterWidget节点,将Target输出连接到Target
Character
输入并且将合适的变量连接到Target Panel的输入,如下:

CombatUI Events

最后在我们的游戏模式中的战斗开始的地方生成这个UI,并且在战斗结束的时候摧毁这个UI,在RPGGameMode的头文件中,添加一个UCombatUIWidget指针和一个创建这个战斗UI的类(我们可以选择一个蓝图控件来继承我们的CombatUIWidget类):

  • RPGGameMode.h
UPROPERTY()
UCombatUIWidget* CombatUIInstance;

UPROPERTY( EditDefaultsOnly, BlueprintReadOnly, Category = "UI" )
TSubclassOf<class UCombatUIWidget> CombatUIClass;

在我们的TestCombat函数中,我们如下创建我们的空间实例:

  • RPGGameMode.cpp

this->CombatUIInstance = CreateWidget<UCombatUIWidget>( GetGameInstance(),
this->CombatUIClass );
this->CombatUIInstance->AddToViewport();

for( int i = 0; i < gameInstance->PartyMembers.Num(); i++ )
{
    this->CombatUIInstance->AddPlayerCharacterPanel( gameInstance->PartyMembers[i] );
}

for( int i = 0; i < this->enemyParty.Num(); i++ )
{
    this->CombatUIInstance->AddEnemyCharacterPanel( this->enemyParty[i] );
}

上面的代码创建了窗口,然后添加到视图,接着分别为玩家和敌人调用他们的AddPlayerCharacterPanelAddEnemyCharacterPanel函数。

在战斗结束时,我们需要从视图中移除窗口,并且设置引用为空,之后他们会被垃圾回收:

  • RPGGameMode.cpp
this->CombatUIInstance->RemoveFromViewport();
this->CombatUIInstance = nullptr;

现在,如果你运行游戏,你可以看见哥布林和玩家的统计数据,他们的HP都会持续的减少直到哥布林的血量为0。然后界面消失了(因为战斗结束了)。

下一步,我们要用玩家在UI上选择动作来代替自动决策。

(未完待更新)

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_20309931/article/details/53157259

智能推荐

JWT(Json Web Token)实现无状态登录_无状态token登录-程序员宅基地

文章浏览阅读685次。1.1.什么是有状态?有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。缺点是什么?服务端保存大量数据,增加服务端压力 服务端保存用户状态,无法进行水平扩展 客户端请求依赖服务.._无状态token登录

SDUT OJ逆置正整数-程序员宅基地

文章浏览阅读293次。SDUT OnlineJudge#include<iostream>using namespace std;int main(){int a,b,c,d;cin>>a;b=a%10;c=a/10%10;d=a/100%10;int key[3];key[0]=b;key[1]=c;key[2]=d;for(int i = 0;i<3;i++){ if(key[i]!=0) { cout<<key[i.

年终奖盲区_年终奖盲区表-程序员宅基地

文章浏览阅读2.2k次。年终奖采用的平均每月的收入来评定缴税级数的,速算扣除数也按照月份计算出来,但是最终减去的也是一个月的速算扣除数。为什么这么做呢,这样的收的税更多啊,年终也是一个月的收入,凭什么减去12*速算扣除数了?这个霸道(不要脸)的说法,我们只能合理避免的这些跨级的区域了,那具体是那些区域呢?可以参考下面的表格:年终奖一列标红的一对便是盲区的上下线,发放年终奖的数额一定一定要避免这个区域,不然公司多花了钱..._年终奖盲区表

matlab 提取struct结构体中某个字段所有变量的值_matlab读取struct类型数据中的值-程序员宅基地

文章浏览阅读7.5k次,点赞5次,收藏19次。matlab结构体struct字段变量值提取_matlab读取struct类型数据中的值

Android fragment的用法_android reader fragment-程序员宅基地

文章浏览阅读4.8k次。1,什么情况下使用fragment通常用来作为一个activity的用户界面的一部分例如, 一个新闻应用可以在屏幕左侧使用一个fragment来展示一个文章的列表,然后在屏幕右侧使用另一个fragment来展示一篇文章 – 2个fragment并排显示在相同的一个activity中,并且每一个fragment拥有它自己的一套生命周期回调方法,并且处理它们自己的用户输_android reader fragment

FFT of waveIn audio signals-程序员宅基地

文章浏览阅读2.8k次。FFT of waveIn audio signalsBy Aqiruse An article on using the Fast Fourier Transform on audio signals. IntroductionThe Fast Fourier Transform (FFT) allows users to view the spectrum content of _fft of wavein audio signals

随便推点

Awesome Mac:收集的非常全面好用的Mac应用程序、软件以及工具_awesomemac-程序员宅基地

文章浏览阅读5.9k次。https://jaywcjlove.github.io/awesome-mac/ 这个仓库主要是收集非常好用的Mac应用程序、软件以及工具,主要面向开发者和设计师。有这个想法是因为我最近发了一篇较为火爆的涨粉儿微信公众号文章《工具武装的前端开发工程师》,于是建了这么一个仓库,持续更新作为补充,搜集更多好用的软件工具。请Star、Pull Request或者使劲搓它 issu_awesomemac

java前端技术---jquery基础详解_简介java中jquery技术-程序员宅基地

文章浏览阅读616次。一.jquery简介 jQuery是一个快速的,简洁的javaScript库,使用户能更方便地处理HTML documents、events、实现动画效果,并且方便地为网站提供AJAX交互 jQuery 的功能概括1、html 的元素选取2、html的元素操作3、html dom遍历和修改4、js特效和动画效果5、css操作6、html事件操作7、ajax_简介java中jquery技术

Ant Design Table换滚动条的样式_ant design ::-webkit-scrollbar-corner-程序员宅基地

文章浏览阅读1.6w次,点赞5次,收藏19次。我修改的是表格的固定列滚动而产生的滚动条引用Table的组件的css文件中加入下面的样式:.ant-table-body{ &amp;amp;::-webkit-scrollbar { height: 5px; } &amp;amp;::-webkit-scrollbar-thumb { border-radius: 5px; -webkit-box..._ant design ::-webkit-scrollbar-corner

javaWeb毕设分享 健身俱乐部会员管理系统【源码+论文】-程序员宅基地

文章浏览阅读269次。基于JSP的健身俱乐部会员管理系统项目分享:见文末!

论文开题报告怎么写?_开题报告研究难点-程序员宅基地

文章浏览阅读1.8k次,点赞2次,收藏15次。同学们,是不是又到了一年一度写开题报告的时候呀?是不是还在为不知道论文的开题报告怎么写而苦恼?Take it easy!我带着倾尽我所有开题报告写作经验总结出来的最强保姆级开题报告解说来啦,一定让你脱胎换骨,顺利拿下开题报告这个高塔,你确定还不赶快点赞收藏学起来吗?_开题报告研究难点

原生JS 与 VUE获取父级、子级、兄弟节点的方法 及一些DOM对象的获取_获取子节点的路径 vue-程序员宅基地

文章浏览阅读6k次,点赞4次,收藏17次。原生先获取对象var a = document.getElementById("dom");vue先添加ref <div class="" ref="divBox">获取对象let a = this.$refs.divBox获取父、子、兄弟节点方法var b = a.childNodes; 获取a的全部子节点 var c = a.parentNode; 获取a的父节点var d = a.nextSbiling; 获取a的下一个兄弟节点 var e = a.previ_获取子节点的路径 vue