(UE C++)Programming with C++ 章节学习

Programming with C++

学习官方文档的笔记,相当于人工翻译,正在学习,边学边更,如有错误,非常感谢您能指正。

会不断打磨这篇和其它章节的笔记,从随笔变成文章。

先是一些入门的小东西。

Tick()

Tick():Actor出现后每一帧都会call它,参数为上一次call它到现在的间隔时间,通常即为帧与帧之间的间隔时间,如果不需要该函数,请关掉它,能节省一小部分性能,记住也要把Constructor里相关的东西删除指的就是

PrimaryActorTick.bCanEverTick = true;

UPROPERITY()

说明一下,_BlueprintReadOnly_相当于表示该属性为const,关于UPROPERTY宏更多的参数,参考Link。下面举个例。

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()
public:

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    int32 TotalDamage;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    float DamageTimeInSeconds;

    UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Transient, Category="Damage")
    float DamagePerSecond;

    ...
};

Transient

UPROPERTY宏的参数,表示短暂的,说明该属性加载时会被填充为0;

PostInitProperties()

当某个属性的初始化值需要由设计师在编辑内设置好其它属性的值,用这些值来产生该初始化值然后赋予,这就需要用到_Super::PostInitProperties()_函数,如下,便能在运行时也能改变那个值。

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();

    CalculateValues();
}

void AMyActor::CalculateValues()
{
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
    //DamagePerSecond,即为那个需要其他值来赋予值的属性
}

#ifdef WITH_EDITOR
void AMyActor::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    CalculateValues();

    Super::PostEditChangeProperty(PropertyChangedEvent);
}
#endif

_PostEditChangeProperty()_函数继承自Actor,当所属Actor的属性在编辑器被改变时会触发调用。

Super

是子类对父类的别称。

BlueprintImplementableEvent

UFUNCTION宏的一个参数,用来使函数被认定为是从蓝图中调用的,但蓝图中没有定义该函数,then do nothing。如果想有一个默认的函数体,使用_BlueprintNativeEvent_,并提供额外的默认函数,命名为[FuncionName]_Implementation,举例。

UFUNCION(BlueprintNativeEvent)
void CalledFromCpp();

void CalledFromCpp_Implementation();

//再实现它
void [ClassName]::CalledFromCpp_Implementation()
{
    //do something
}

好的,讲解正式开始,下面介绍四大gameplay class。

UObject

它和UClass搭配,提供了UE最重要的一些services(如下),是引擎最基本的两个类。

  • 映射properties和methods
  • properities的序列化
  • 垃圾回收
  • 通过名字寻找UObject
  • 给propeties配置值
  • properties和methods的网络工作支持

每一个继承自UObject的类的实例,引擎都会自动创建一个包含所有元数据(metadata)的UClass供其使用。

AActor

继承自UObject,要么被直接放置再world当中,要么在运行中通过gameplay系统被加入world中。所有可以被放入level中的对象都继承自该类。它可以被显示消除,也可以通过垃圾回收系统自动消除,还可以通过Lifespan决定它存在多久,然后自动消除。

它的生命周期简单来说就三件事,BeginPlay(), Tick(), EndPlay(),直观一点就是被放入world,做事情,从level里消失。因为操纵一个Actor合理变化十分复杂,引擎提供了一个method,SpawnActor,是UWorld的一个成员。

UActorComponent

即Actor的组件,RootComponent是Actor的成员,根组件嘛,另外,组件和Actor共享Tick。

UStruct

注意,UStruct并不从UObject继承,没有垃圾回收等机制。它的内部应该全部为纯数据。

Unreal Reflection System

gameplay类用一些特殊的宏

来让我们轻易实现映射。下面介绍几种。

Macro Description
UStruct() 让引擎为这个struct产生映射数据
GENERATED_BODY() 为该类产生模板式的constructor

另外,所有产生的映射数据都会存到[ClassName].generated.h文件中,GENERATED_BODY()也在里面。

Object/Actor Iterators

Object Iterators可以将UObject所有的实例包括子类实例全都迭代一遍。如下

for (TObjectIterator<UObject> It; It; It++)
{
	UObject* CurrentObject=*It;
	UE_LOG(LogTemp, Log, TEXT("Found UObject named: %s"), *CurrentObjec->GetName());
}

TObjectIterator<>里也可以指定UObject的子类,如那么将迭代该子类和该子类的子类的所有实例。

注意使用在PIE中使用Object Iterator会导致意外错误。编辑器加载完成后,迭代器会归还所有被放入world的对象实例和编辑器正在使用的实例。

Actor Iterator相当于TObjectIterator,Actor Iterator不会产生上述问题,且只归还被放入current level的对象实例。创建一个Actor Iterator,需要给它指向一个UWorld实例的指针。

APlayerController* MyPC = GetMyPlayerControllerFromSomewhere();
UWorld* World = MyPC->GetWorld();

// Like object iterators, you can provide a specific class to get only objects that are or derive from that class
for (TActorIterator<AEnemy> It(World); It; ++It)
{
    // ...
}

Memory Management and Garbage Collection

说在前头,垃圾回收机制,清除的是不再被引用(被指针指向)或已被显式标记为即将被回收的内存。

你创建了一个类A,在类中定义一些成员指针变量,其类型是B,它会指向一块内存,该指针便是对这块内存的一个reference,垃圾回收便是把这指针所指向的内存回收,并把指针设置为nullptr。

至于你所建的类A,在其它类中,可能会有A类型的指针,它申请一块内存,至于这块内存是否在垃圾回收系统范畴,就看你建的这个类A,是否符合规定。

所以,你在你所建立的类里应该讨论的是成员变量,讨论类本身在类内是没有意义的。

UObject

UE使用映射系统来执行垃圾回收,需要垃圾回收机制的类需是UObject或其子类。

垃圾回收有一个概念叫做root set,即一个包含一些对象的列表,回收系统保证不会回收这些对象。把这个列表想象成一棵树,树所触及不到的实例对象,全都当垃圾回收了。垃圾回收会一轮一轮在固定时间进行。

UObject不会被当作垃圾回收的条件有三种:

  1. UObject对象被加入到root set上(调用AddRoot函数)。
  2. 直接或者间接被root set 里的对象引用(如UPROPERTY宏修饰的UObject成员变量 注:UObject放在UPROPERTY宏修饰的TArray、TMap中也可以)
  3. 直接或间接被存活的FGCObject对象引用(后面会讲)

举例

void CreateDoomedObject()
{
    MyGCType* DoomedObject = NewObject<MyGCType>();
}

这里的DoomedOjbect指针就没有被UPROPERTY宏修饰(或在被UPROPERTY宏修饰的UE容器类里),即root set触及不到,会被垃圾回收消除。

Acotr

除了level关闭,Actor一般不会被垃圾回收,它们产生后,一般需要手动调用消除函数(只是从root set中移除,还需等待下轮垃圾回收),这之后它们会立马被排除在world外,然后垃圾回收系统就能检测到异端了,会在下一轮把它回收掉。举例

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY()
    MyGCType* SafeObject;

    MyGCType* DoomedObject;

    AMyActor(const FObjectInitializer& ObjectInitializer)
        : Super(ObjectInitializer)
    {
        SafeObject = NewObject<MyGCType>();
        DoomedObject = NewObject<MyGCType>();
    }
};

void SpawnMyActor(UWorld* World, FVector Location, FRotator Rotation)
{
    World->SpawnActor<AMyActor>(Location, Rotation);
}

当我们调用SpawnMyActor函数时,MyActor会产生在world里。SafeObject前有UPROPERTY宏修,但DoomedObject并没有,它会被垃圾回收机制检测到并消除,留下一个dangling(空悬)指针。(解释一下,野指针是没有初始化的指针,根本不知道指的啥;空悬指针是指那种生命周期比所指对象还长的指针,在所指对象被回收后,它仍指向那块内存,若系统给那块内存分配了东西,会有意外发生)

但注意,当一块存储UObject的内存被回收之后,所有被UPROPERTY宏修饰并指向这块内存的指针都会被设置为nullptr,这样就消除了空悬指针,这也使得你在使用这些指针的时候,要先确认一下是否为nullptr,因为还有一点,手动调用函数消除实际上是把该指针所指对象从root set里移除,并等待下一轮的垃圾回收,用IsPendingKill检验是否在等待

if (MyActor->SafeObject != nullptr)
{
    // Use SafeObject
}

UStruct

没有垃圾回收机制,非要使用它的动态实例,则需要智能指针的登场。

Non-UObject References

普通的c++类(非继承自UObject)需继承自FGCObject类,并重载AddReferenceObject()就也能添加对其的reference且不会被垃圾回收系统强制回收。需要说明的是,垃圾回收系统是一种无差别攻击系统,不在名单里的统统消灭。举例

class FMyNormalClass : public FGCObject
{
public:
    UObject* SafeObject;

    FMyNormalClass(UObject* Object)
        : SafeObject(Object)
    {
    }

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(SafeObject);
    }
};

我们用FReferenceCollector来手动添加对该UObject的hard reference,而在对象被删除且destructor正常运行时,它会自动消除所有reference。

更详细的剖析

说一下,垃圾回收系统有两套,分别处理UObject和非UObject,想要创建的类能够被加入垃圾回收系统,只要让继承自UObject类的变量套上UPROPERTY的宏就可以了,因为这样就是被root set里的对象引用了,而继承自非UObject类的变量——则需要干以下几件事。

  • 让这个类一开始写的时候就继承FGCObject类。

  • 如果成员变量中有UObject类,在复写的AddReferencedObjects()方法中,将引用的UObject变量加入到Collector中即可。

  • 如果成员变量中有 非UObject类,则需要将其声明为UE自定义的智能指针。\

UE Type

Class

特殊的命名规则,给予特殊的便利与保护。

  • 继承自AActor,名前会加上A
  • 继承自UObject,名前会加上U
  • Enums类型前会加上E
  • Interface类型前会加上I
  • Template类型前会加上T
  • 继承自SWidget (Slate UI),名前会加上S
  • 其它都会加上F

Number

整数:

  • int8/uint8: 8-bit signed/unsigned integer
  • int16/uint16: 16-bit signed/unsigned integer
  • int32/uint32: 32-bit signed/unsigned integer
  • int64/uint64: 64-bit signed/unsigned integer

浮点数:

​ float (32-bit) and double (64-bit)

UE中还有一个Template,TNumericLimits,可以查找一类型数字的最大最小范围

String

UE提供了很多,但这一篇里文档没讲。

FString

是一种mutable string,用TEXT(" ")创建,日志输出一般都是用它。

FText

与FString类似,但它是localized text,两种方式创建,一是用NSLOCTEXT宏,需要a namespace, key, and a value三个参数;二是LOCTEXT宏,只需namespace,value两个参数,举例

//第一种
FText MyText = NSLOCTEXT("Game UI", "Health Warning Message", "Low Health!")

//第二种
// In GameUI.cpp
#define LOCTEXT_NAMESPACE "Game UI"

//...
FText MyText = LOCTEXT("Health Warning Message", "Low Health!")
//...

#undef LOCTEXT_NAMESPACE
// End of file

FName

它主要用来存储十分常用的字符串,如果有多个对象引用同一个字符串,FName能使用较小的空间存储索引来映射(map)到给定字符串,它更快也是因为引擎能够检查其索引值来确认其是否匹配,而无须检查每一个字符是否相同。

TCHAR

TCHAR类型是独立于所用字符集存储字符,考虑到的是字符集或许会因平台而异。实际上,UE的字符串使用 TCHAR 数组来存储 UTF-16 编码的数据。可以使用返回TCHAR的overloaded dereference operator来访问the raw data。

某些函数要用它,例如 FString::Printf()

FString Str1 = TEXT("World");
int32 Val1 = 123;
FString Str2 = FString::Printf(TEXT("Hello, %s! You have %i points."), *Str1, Val1);

"%s" 字符串格式说明符要的是TCHAR,一般就给它_*FString_。

FChar类提供一系列static utility function处理TCHAR的单个字符,举例

TCHAR Upper('A');
TCHAR Lower = FChar::ToLower(Upper); // 'a'

接下来介绍一些Container。

TArray

类似于std::vector,但有更多功能,下面是一些普通的操作。

TArray<AActor*> ActorArray = GetActorArrayFromSomewhere();

// 看有多少elements
int32 ArraySize = ActorArray.Num();

// 第一个元素的索引为0
int32 Index = 0;

// 检索一个值。
AActor* FirstActor = ActorArray[Index];

// 在TArray末尾添加element
AActor* NewActor = GetNewActor();
ActorArray.Add(NewActor);

// 添加一个TArray里本不存在的element,若存在,则不添加
ActorArray.AddUnique(NewActor); 

// 将TArray里的所有NewActor移除
ActorArray.Remove(NewActor);

// 移除索引处的值,并将后面的所有值往前挪一位,即不留空位
ActorArray.RemoveAt(Index);

// 移除索引处的值,与上不同,会将TArray里最后一个值挪到空缺处
ActorArray.RemoveAtSwap(Index);

// 清空
ActorArray.Empty();

另外,像之前说的一样,被UPROPERTY宏修饰的TArray的UObject成员拥有垃圾回收的权限。

UCLASS()
class UMyClass : UObject
{
    GENERATED_BODY();

    // ...

    UPROPERTY()
    AActor* GarbageCollectedActor;

    UPROPERTY()
    TArray<AActor*> GarbageCollectedArray;

    TArray<AActor*> AnotherGarbageCollectedArray;
    
    // 是吧,这些也都是指针
};

TMap

类似于std::map,具体方法文档在该处给了个实例,这里截取一小部分,简单明了。

 TMap<FIntPoint, FPiece> Data;
 
 Data.Contains(Position);
 
 FPiece Value = Data[Position];
 
 Data.Add(Position, NewPiece);
 
 Data.Remove(OldPosition);
 
 Data.Empty();

TSet

类似于std::set,直接上例子,也是简单明了

TSet<AActor*> ActorSet = GetActorSetFromSomewhere();

int32 Size = ActorSet.Num();

AActor* NewActor = GetNewActor();
ActorSet.Add(NewActor);

if (ActorSet.Contains(NewActor))
{
    // ...
}

ActorSet.Remove(NewActor);

ActorSet.Empty();

// 创造一个包含TSet里所有elements的TArray
TArray<AActor*> ActorArrayFromSet = ActorSet.Array();

Container Iterator

直接上例子。

void RemoveDeadEnemies(TSet<AEnemy*>& EnemySet)
{
    for (auto EnemyIterator = EnemySet.CreateIterator(); EnemyIterator; ++EnemyIterator)
    {
        AEnemy* Enemy = *EnemyIterator;
        if (Enemy.Health == 0)
        {
            // RemoveCurrent()是TSet和TMap的方法
            EnemyIterator.RemoveCurrent();
        }
    }
}
// 退回到前一个element
--EnemyIterator;

// 前进或后退offset个element
EnemyIterator += Offset;
EnemyIterator -= Offset;

// 获得迭代器现在的索引
int32 Index = EnemyIterator.GetIndex();

// 让迭代器回到第一个element
EnemyIterator.Reset();

For-Loop

下面是for循环适应于TArray,TSet,TMap的用法。

// TArray
TArray<AActor*> ActorArray = GetArrayFromSomewhere();
for (AActor* OneActor : ActorArray)
{
    // ...
}

// TSet - Same as TArray
TSet<AActor*> ActorSet = GetSetFromSomewhere();
for (AActor* UniqueActor : ActorSet)
{
    // ...
}

// TMap - Iterator returns a key-value pair
TMap<FName, AActor*> NameToActorMap = GetMapFromSomewhere();
for (auto& KVP : NameToActorMap)
{
    FName Name = KVP.Key;
    AActor* Actor = KVP.Value;

    // ...
}

从上面的代码中可以看到auto不会自动识别指针和引用,需要手动添加 * 或 & 。

Using Your Own Types with TSet/TMap (Hash Functions)

TSet和TMap内部都需要哈希函数,大部分UE types都已经定义了专属的哈希函数,如果你自定义的类需要用在TSet或作为key用在TMap里,需要提供一个参数为你定义的这个类的指针或引用,返回值为uint32,这个返回值需是你的类独有代号,举例。

class FMyClass
{
    uint32 ExampleProperty1;
    uint32 ExampleProperty2;

    // Hash Function作为friend
    friend uint32 GetTypeHash(const FMyClass& MyClass)
    {
        // HashCombine(),内部函数,结合两个哈希值
        uint32 HashCode = HashCombine(MyClass.ExampleProperty1, MyClass.ExampleProperty2);
        return HashCode;
    }

    // 为了演示证明,使用两个相同类型的对象
    // should always return the same hash code.
    bool operator==(const FMyClass& LHS, const FMyClass& RHS)
    {
        return LHS.ExampleProperty1 == RHS.ExampleProperty1
            && LHS.ExampleProperty2 == RHS.ExampleProperty2;
    }
};

如果用指针作为key,即TSet<ClassName*>,那么上面相应位置应该这么用:

uint32 GetTypeHash(const ClassName* ValueName)。

Asserts

首先回顾一下c++中关于assert的知识:

assert,意思是断言,需包含头文件assert.h。assert其实是宏定义,而非函数,用在程序调试阶段检查错误,判断expression是否为假,为假时,会调用abort报警。

void assert(int expression);

// 举例
assert(("a必须大于10", a > 10));
// 或者
assert(a > 10 && "a必须大于10");	

// 输出结果样式如下
Assertion failed: expression, file [FileName], line [num].

assert只有在Debug中才有效,如果编译为Release则被忽略。
如果不想使用它,可以在#include语句之前,插入#define NDEBUG,就可以禁用assert了。

assert通常用来检查三种情况,指针是否为空、除数发是否为零、函数是否递归运行,当然代码要求的其他重要假设也可能会用到,但缺点是效率低。

某些情况下,assert 能在真正的崩溃 (crash)发生前,发现造成延迟崩溃的bug,像是删掉在之后的Tick中会用到的对象,帮助找到崩溃的源头,当然其最关键的feature,像之前说的一样,不会出现在shipping code中。

好,回到UE。

UE提供assert的三种等价体系, checkverifyensure,三个有细微差别,但主要作用相同,都声明于 AssertionMacros.h 头文件中。(注意这些都是体系,每个里面又很多可用的宏)

Check

check体系是三个当中最接近assert的,当在参数里发现为false的表达式时,立马停止运行,默认也不会在shipping版本中运行。下面是check体系的可用宏。

Macro Parameters Behavior
check / checkSlow Expression Expression为false时停止运行
checkf / checkfSlow Expression, FormattedText, ... Expression为false时停止运行,并在日志中输出FormattedText
checkCode Code 在do-while循环中执行Code,while条件硬性规定为false,即只运行一次,主要用来准备其它Check所需要的信息
checkNoEntry (none) 一旦触及,停止运行,类似于check(false),但主要倾向于说明程序不能走向这里
checkNoReentry (none) 第二次触及这里,停止运行,就是只允许紧接其后的代码运行一次
checkNoRecursion (none) 第二次到这儿如果没有离开当前作用域,停止运行
unimplemented (none) 一旦触及,停止运行,类似于check(false),主要用于设计上希望被override且不会被调用的虚函数

这些宏当中,除了以Slow结尾的只在Debug中运行,其余的在Debug,Development中均可运行。

UE的Check体系中保留有一个USE_CHECKS_IN_SHIPPING的宏定义,用以标记Check检查可在所有版本执行,其默认值为0,主要用于怀疑check中的代码在修改值,或者发现仅存于发布版本的bug。

// 这个函数的传入参数JumpTarget如果是nullptr,那么运行会停止
void AMyActor::CalculateJumpVelocity(AActor* JumpTarget, FVector& JumpVelocity)
{
    check(JumpTarget != nullptr);
    // 计算速度需要JumpTarget,这里保证它不是nullptr
}
// HasCycle()检查MyLinkedList中有没有闭环,因为检查闭环很费时间,我们只在Debug中检查
checkfSlow(!MyLinkedList.HasCycle(), TEXT("Found a cycle in the list!"));
// (Walk through the list, running some code on each element.)
// IsEverythingOk()没有额外的作用,就是看有没有致命性的错误
// If this happens, terminate with a fatal error.
// 因为这段没有其它作用且只是诊断检查,所以无需在shipping版本中运行
checkCode(
    if (!IsEverythingOK())
    {
        UE_LOG(LogUObjectGlobals, Fatal, TEXT("Something is wrong with %s! Terminating."), *GetFullName());
    }
);
// 如果我们有一个新的Shape Type却没加入这段switch中,就会停止运行
switch (MyShape)
{
    case EShapes::S_Circle:
        // (Handle circles.)
        break;
    case EShapes::S_Square:
        // (Handle squares.)
        break;
    default:
        // 不应该有没说明的Shape Type,所以此路不通
        checkNoEntry();
        break;
}

Verify

和Check体系差不多,但它可以在Check被禁掉的版本中仍计算表达式的值,注意这并不会触发运行停止,所以当表达式需要在诊断检查之外独立运行时,才使用该宏。

举个例子:如果要停止运行并检查(即断言检查)一个函数,假设函数返回bool值并以此作为断言参数,此时check和verify的行为一致,而在shipping版本中,它们开始有差异,verify在发行版本中会忽略函数的返回值(即不进行断言检查),但仍然会执行函数,而check则不会执行。

就是说,如果需要断言检查的参数表达式始终执行,则使用verify体系。

Macro Parameters Behavior
verify / verifySlow Expression Expression为false时,停止运行
verifyf / verifyfSlow Expressin, FormattedText, ... Expression为false时,停止运行,并在日志中输出FormattedText

同Check体系一样,这些宏当中,除了以Slow结尾的只在Debug中运行,其余的在Debug,Development中均可运行,而且如上面所说,在所有版本中,包括shipping版本,Verify体系都会计算表达式的值。

同Check体系一样,Verify体系留有一个USE_CHECKS_IN_SHIPPING的宏定义,默认为1,如果overide它,那么在除了1的其它所有情况下,Verify体系都只会计算表达式的值,而不会停止运行。

另外verifyfSlow宏貌似在某个版本中被删除了。

// 设置Mesh的值并确认是否为null,如果是,停止运行
// 这里使用verify的原因是不管怎样,Mesh都需要设置一个值
verify((Mesh = GetRenderMesh()) != nullptr);

Ensure

类似于Verify体系,Ensure和Verify一样始终(在shipping中也如此)计算表达式的值,但不同的是,它不会停止运行,而是通知crash reporter,程序接着run。

需要特别注意的是,为了防止crash reporter死命报告错误,一次引擎或编辑器会话中触发 ensure 断言只会报告一次,如果想总是报告,用带有Always的Ensure宏。

Macro Parameters Behavior
ensure Expression Expression为false时,通知crash reporter
ensureMsgf Expression, FormattedText, ... Expression为false时,通知crash reporter,并在日志中输出FormattedText
ensureAlways Expression 带有Always
ensureAlwaysMsgf Expression, FormattedText, ... 带有Always

在所有版本中都会计算表达式的值,但只会在Debug, Development, Test, and Shipping Editor builds版本中通知crash reporter。

// 这段代码可能会在shipping版本中有一个细小的错误,小到无需为它停止程序,就是想到也许已经修好了它,来验证一下
void AMyActor::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);
    // 确保bWasInitialized是true,不是的话就会在log中输出信息
    if (ensureMsgf(bWasInitialized, TEXT("%s ran Tick() with bWasInitialized == false"), *GetActorLabel()))
    {
        // (Do something that requires a properly-initialized AMyActor.)
    }
}

(说一点,shipping editor版本被删掉了)

Programming Basics

这部分都是简单介绍一下相应的环节,文档在这部分的最后都留下了小练习,这里就贴个链接:Link

Game-Controlled Cameras

讲解如何控制Cameras,首先把Camera扔到level里。

创建一个继承自AActor的c++类,命名为CameraDirector。

// 在.h文件中添加以下成员变量,加到第二个个public里,为什么有两个public的区分(???)


UPROPERTY(EditAnywhere)
AActor* CameraOne;

UPROPERTY(EditAnywhere)
AActor* CameraTwo;

float TimeToNextCameraChange;


// 然后在ACameraDirector::Tick函数里添加以下代码

const float TimeBetweenCameraChanges = 2.0f;
const float SmoothBlendTime = 0.75f;
TimeToNextCameraChange -= DeltaTime;
if (TimeToNextCameraChange <= 0.0f)
{
    TimeToNextCameraChange += TimeBetweenCameraChanges;

    // 获取自己控制的actor,这里的重点就是获取自己的APlayerController
    // APlayerController是一个类,为什么这个类能在这里用,你引入的头文件里面也引入了其它头文件,错综复杂,最终绝对引入了APlayerController.h,至于UGameplayStatics类,它就在GameplayStatics.h里
    APlayerController* OurPlayerController = UGameplayStatics::GetPlayerController(this, 0);
    if (OurPlayerController)
    {
        // 开始换Camera了
        if ((OurPlayerController->GetViewTarget() != CameraOne) && (CameraOne != nullptr))
        {
            // Cut instantly to camera one.
            OurPlayerController->SetViewTarget(CameraOne);
        }
        else if ((OurPlayerController->GetViewTarget() != CameraTwo) && (CameraTwo != nullptr))
        {
            // Blend smoothly to camera two.
            OurPlayerController->SetViewTargetWithBlend(CameraTwo, SmoothBlendTime);
        }
    }
}
// 这段代码会让我们每三秒切换一次Camera

接下来在Editor的C++文件夹里找到你的CameraDirector类,扔进level里,再在Detial面板里设置CameraOne和CameraTwo,其实设置成不是CameraActor的类也行(阿这)。

文档里的练习跟着做一下。Exercise

Player Input and Pawns

用Pawn类来接受player输入。

创建一个继承自Pawn的c++类,就命名为MyPawn。

// 在Constructor里添加以下代码,先让它能自动接受输入信息,并将它设置成由第一位player控制
AutoPossessPlayer = EAutoReceiveInput::Player0;

// 在.h文件里引入以下头文件
#include "Kismet/GameplayStatics.h"
#include "Camera/CameraComponent.h"
// 在头文件里创建Component,如下
UPROPERTY(EditAnywhere)
USceneComponent* OurVisibleComponent;
UPROPERTY(EditAnywhere)
UCameraComponent* OurCamera;


// 回到Constructor里,添加以下代码

// 创建一个虚的root component,相当于只有一个pivot point,这里的RootComponent是Actor的成员变量,即再Actor.h里定义的
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootComponent"));

// 创建一个Camera Component,并给之前声明的OurVisibleComponent赋值
// 这里的CreateDefaultSubobject的返回值就是USenceComponent及其子类(写在<>里的),用来创建一个实例
OurCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("OurCamera"));
OurVisibleComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("OurVisibleComponent"));

// 将Camera和VisibleComponent连到root component上,并转一下Camera
// 这里的Relative位置应该就是根组件的相对位置,也就是这虚假的root component所在的pivot point的相对位置
OurCamera->SetupAttachment(RootComponent);
OurCamera->SetRelativeLocation(FVector(-250.0f, 0.0f, 250.0f));
OurCamera->SetRelativeRotation(FRotator(-45.0f, 0.0f, 0.0f));
OurVisibleComponent->SetupAttachment(RootComponent);

记住把MyPawnActor扔到level里后还需选中它然后选择一个StaticMesh给它用,先选择这个组件再把StaticMesh拖过去,记住一定要点到那个用来Visual的组件。

1

有两种映射输入的类型:Action和Axis(轴)。

Action Mapping:适用于Yes/No的输入,像是按下鼠标或手柄,按下、松开、双击或短按,长按都可以用这种映射方式。

Axis Mapping:适用于那种连续的输入,像是一直推着手柄操纵杆,或是鼠标光标的位置,即使没有发生改变,它们仍会每一帧地报告自己的值。

尽管设置银蛇输入可以在代码中进行,但一般我们在Editor里弄这玩意。

在Project Setting->Engine->Input自己去设置吧,很简单,这里设置的是你Action Mapping的按键,Axis Mapping的按键和每一帧会产生的值。

下面就在代码中使用这些值。

// 首先再头文件里声明这些函数和变量
//Input functions
void Move_XAxis(float AxisValue);
void Move_YAxis(float AxisValue);
void StartGrowing();
void StopGrowing();
// 注意与ActionMapping对应的函数无需参数

//Input variables
FVector CurrentVelocity;
bool bGrowing;

// 在.cpp文件里实现它们

void AMyPawn::Move_XAxis(float AxisValue)
{
    // 每秒向前或向后移动100个单元,这里的单元应该指AxisValue,即你在Editor里设置的数字
    CurrentVelocity.X = FMath::Clamp(AxisValue, -1.0f, 1.0f) * 100.0f;
}

void AMyPawn::Move_YAxis(float AxisValue)
{
    // 每秒向前或向后移动100个单元,这里的单元应该指AxisValue,即你在Editor里设置的数字
    CurrentVelocity.Y = FMath::Clamp(AxisValue, -1.0f, 1.0f) * 100.0f;
}
// FMath::Clamp()函数能限定值在一定范围内,如果有多个键能对该值造成影响,可以防止同时按下这几个键时,该值偏离过大

// 注意与ActionMapping对应的函数无需参数
void AMyPawn::StartGrowing()
{
    bGrowing = true;
}

void AMyPawn::StopGrowing()
{
    bGrowing = false;
}

// 下面代码添加到AMyPawn::SetupPlayerInputComponent里去,就是将按键所传达的值将按键信息与上面函数中接受的参数绑定

// 绑定Action Mapping,实质是若“Grow”这个键按下,传个在Editor里设定好的值到相应的函数里去
InputComponent->BindAction("Grow", IE_Pressed, this, &AMyPawn::StartGrowing);
InputComponent->BindAction("Grow", IE_Released, this, &AMyPawn::StopGrowing);

// 绑定Axis Mapping,实质是若“Move_X/Y”这个键按下,传个在Editor里设定好的值到相应的函数里去
InputComponent->BindAxis("MoveX", this, &AMyPawn::Move_XAxis);
InputComponent->BindAxis("MoveY", this, &AMyPawn::Move_YAxis);


// 上面都是绑定,下面就是绑定后能用按下按键所传入的值做些什么
// 基与“Grow” Action放大或缩小
{
    float CurrentScale = OurVisibleComponent->GetComponentScale().X;
    if (bGrowing)
    {
        CurrentScale += DeltaTime;
    }
    else
    {
        CurrentScale -= (DeltaTime * 0.5f);
    }
    // 确保不会比一开始的尺寸小,以及一次不会变大两倍
    CurrentScale = FMath::Clamp(CurrentScale, 1.0f, 2.0f);
    OurVisibleComponent->SetWorldScale3D(FVector(CurrentScale));
}

// 基与“Move_X/Y”Axis控制移动
{
    if (!CurrentVelocity.IsZero())
    {
        FVector NewLocation = GetActorLocation() + (CurrentVelocity * DeltaTime);
        SetActorLocation(NewLocation);
    }
}

Components and Collision

介绍如何用Components让Pawn于Collision等等交互。

一样,创建一个继承自Pawn的c++类,命名为CollidingPawn。na

// 在.h文件里加入以下成员变量,这里加上class,作用是声明,因为懒得在头文件里为这一个而引入头文件,另外,有些Component需要在头文件里声明,有些不要,区别在于你需不需要一直追踪它,就是在很多地方使用它,需要那就声明
UPROPERTY()
class UParticleSystemComponent* OurParticleSystem;

// 在.CPP文件里引入以下头文件,都是要用到的,也就实现两个东西,基础的物理碰撞,一点小小的粒子特效(摩擦起火),和弹性Camera
#include "UObject/ConstructorHelpers.h"
#include "Particles/ParticleSystemComponent.h"
#include "Components/SphereComponent.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/SpringArmComponent.h"

// 下面的代码全都加在Constructor里

// 下面设置球形碰撞网格体,并把它设置成root component
USphereComponent* SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("RootComponent"));
// 下面这行位置不要搞反,毕竟*RootComponent是USenceComponent的,是USphereComponent的父类
RootComponent = SphereComponent;
SphereComponent->InitSphereRadius(40.0f);
SphereComponent->SetCollisionProfileName(TEXT("Pawn"));

// 下面再设置可见球体组件,要注意的是,上面我们把球形碰撞体半径设置成了40.0f,下面绑上去的mesh资源实际上半径有50.0f,所以得缩小
UStaticMeshComponent* SphereVisual = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("VisualRepresentation"));
SphereVisual->SetupAttachment(RootComponent);
// 如果想直接在Editor里编辑的话,下面这一步和那个if就都不需要,和之前讲的MyPawn类一样,在类里声明该Component,并设定为EditAnywhere,下面这个指定资源和那个if就都不用了,但if里的那个位置和大小得在编辑器里有所设置
static ConstructorHelpers::FObjectFinder<UStaticMesh> SphereVisualAsset(TEXT("/Game/StarterContent/Shapes/Shape_Sphere.Shape_Sphere"));
if (SphereVisualAsset.Succeeded())
{
    SphereVisual->SetStaticMesh(SphereVisualAsset.Object);
    SphereVisual->SetRelativeLocation(FVector(0.0f, 0.0f, -40.0f));
    SphereVisual->SetWorldScale3D(FVector(0.8f));
}

// 上面这里以及即将要说的下面都要明白一点,就是它们都是直接用代码把mesh资源绑上去,而一般的做法是直接在.h文件里声明Visible Component成员变量并设置为EditAnywhere,然后Constructor里设置该Component,再就是像上一小节所讲的一样,进Editor里直接把mesh拖过去。不过嘛,直接用代码的话,也方便Debug及创建新features

// 下面将之前在头文件里声明的粒子特效组件给实现了
OurParticleSystem = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("MovementParticles"));
// 注意这里将其与SphereVisual相连,并设置在SphereVisual的底部,让其更像脚底生花(火花)
OurParticleSystem->SetupAttachment(SphereVisual);
OurParticleSystem->bAutoActivate = false;
OurParticleSystem->SetRelativeLocation(FVector(-20.0f, 0.0f, 20.0f));
static ConstructorHelpers::FObjectFinder<UParticleSystem> ParticleAsset(TEXT("/Game/StarterContent/Particles/P_Fire.P_Fire"));
if (ParticleAsset.Succeeded())
{
    OurParticleSystem->SetTemplate(ParticleAsset.Object);
}

// 下面添加弹性摄像头(Spring Arm Camera),就是那种一旦你跑快了,视角追不上你的那种感觉
// 说明一下,这里是带有弹性的摄像头,Play后会是第三人称视角,如果不设置摄像头位置或干脆不要这个弹性摄像头组件,那么会默认第一人称视角
USpringArmComponent* SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraAttachmentArm"));
SpringArm->SetupAttachment(RootComponent);
SpringArm->SetRelativeRotation(FRotator(-45.f, 0.f, 0.f));
// 距离pivot point的距离
SpringArm->TargetArmLength = 400.0f;
// 弹性功能的开关,主要体现在按下移动键后摄像头会延迟移动,再配合摄像头移动速度,从而实现弹性
SpringArm->bEnableCameraLag = true;
// 摄像头速度
SpringArm->CameraLagSpeed = 3.0f;
UCameraComponent* Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("ActualCamera"));
// 这里的Socket也相当于组件,是“插”上去的
Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);

// 让Player0的输入信息控制这些玩意
AutoPossessPlayer = EAutoReceiveInput::Player0;

再在Editor里设置输入,如下,记住这些名字要与代码里的PlayerInputComponent->BindAction/Axis()里的第一个参数对应。

这里换一种方法,就是不把控制移动的代码也塞入CollidingPawn类里,而是另建新类来专门设置控制移动,在添加c++类选择继承类时,点击右上角ShowAllClass,然后搜索PawnMoveComponent类,就命名为CollidingPawnMovementComponent。

// 首先需要声明重载一个TickComponent来决定每一帧的运动,它类似于Actor的Tick
public:
	virtual void TickComponent(float DeltaTime, enum EleveTick TickType, FActorComponentTickFunction *ThisTickFunction) override;

// 简单回顾一下virtual和override,virtual会允许其派生类重载该函数,若函数后面加上=0,则为纯虚函数,那么该类无法实现它,只提供一个接口,供子类重载,override能确保该函数是对父类函数的重载,不然会报错

// 接下来在.cpp文件里实现它
void UCollidingPawnMovementComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction)
{
    // 再说明一下,Super会自动代指拥有这个函数的某个父类
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

    // 确保参数都是合规的
    if (!PawnOwner || !UpdatedComponent || ShouldSkipUpdate(DeltaTime))
    {
        return;
    }

    // ConsumeInputVector()能得到并清空我们用来存储movement inputs的内置变量
    FVector DesiredMovementThisFrame = ConsumeInputVector().GetClampedToMaxSize(1.0f) * DeltaTime * 150.0f;
    if (!DesiredMovementThisFrame.IsNearlyZero())
    {
        FHitResult Hit;
        // 这里创建一个FHitResult,用来存储撞击所产生的一些值
        SafeMoveUpdatedComponent(DesiredMovementThisFrame, UpdatedComponent->GetComponentRotation(), true, Hit);

        // 现在就可以用FHitResult所存储的值来判断,撞击后会沿着障碍物表面跑,没有这些的话,会直接黏在障碍物上
        if (Hit.IsValidBlockingHit())
        {
            SlideAlongSurface(DesiredMovementThisFrame, 1.f - Hit.Time, Hit.Normal, Hit);
        }
    }
};

// UPawnMovementComponent类提供了一些很强的的函数,就如上面提到的ConsumeInpuVector(), SafeMoveUpdatedComponent(), SlideAlongSurface(),当然还有其它如Floating Pawn Movement, Spectator Pawn Movement, or Character Movement Component,也都很jb强


两边的基础设置都已经弄好了,现在在CollidingPawn里使用CollidingPawnMovementComponent类。

// 在CollidingPawn头文件里声明,同样,若是懒得为这一个成员而引入一个头文件,那就加个class直接声明
UPROPERTY()
class UCollidingPawnMovementComponent* OurMovementComponent;

// 为了能持续跟踪上面这个成员(???),在CollidingPawn源文件里添加相应的头文件,记得加到GameFramework/Pawn.h下面
#include "CollidingPawnMovementComponent.h"

// 在Constructor里添加下面的代码,为OurMovementComponent添加一个实例,并将其UpdatedComponent成员设为RootComponent,即让它去更新RootComponent
OurMovementComponent = CreateDefaultSubobject<UCollidingPawnMovementComponent>(TEXT("CustomMovementComponent"));
OurMovementComponent->UpdatedComponent = RootComponent;

// 重载下面这个函数,在头文件里声明,该函数由Pawn类提供
virtual UPawnMovementComponent* GetMovementComponent() const override;
// 并在源文件里实现
UPawnMovementComponent* ACollidingPawn::GetMovementComponent() const
{
    return OurMovementComponent;
}

// 接下来就又是运动和特效的一些声明和定义了
void MoveForward(float AxisValue);
void MoveRight(float AxisValue);
void Turn(float AxisValue);
void ParticleToggle();
// 实现它们,都需要确认一下你实例化的自定义PawnMoveComponent子类是否存在
void ACollidingPawn::MoveForward(float AxisValue)
{
    if (OurMovementComponent && (OurMovementComponent->UpdatedComponent == RootComponent))
    {
        OurMovementComponent->AddInputVector(GetActorForwardVector() * AxisValue);
    }
}

void ACollidingPawn::MoveRight(float AxisValue)
{
    if (OurMovementComponent && (OurMovementComponent->UpdatedComponent == RootComponent))
    {
        OurMovementComponent->AddInputVector(GetActorRightVector() * AxisValue);
    }
}

void ACollidingPawn::Turn(float AxisValue)
{
    FRotator NewRotation = GetActorRotation();
    NewRotation.Yaw += AxisValue;
    SetActorRotation(NewRotation);
}

void ACollidingPawn::ParticleToggle()
{
    // 这里就要看你的ParticleSystemComponent有没有实例成功以及Template有没有设置
    if (OurParticleSystem && OurParticleSystem->Template)
    {
        OurParticleSystem->ToggleActive();
    }
}

// 以及在ACollidingPawn::SetupPlayerInputComponent()里映射这些运动定义,ActionMapping和AxisMapping
PlayerInputComponent->BindAction("ParticleToggle", IE_Pressed, this, &ACollidingPawn::ParticleToggle);

PlayerInputComponent->BindAxis("MoveForward", this, &ACollidingPawn::MoveForward);
PlayerInputComponent->BindAxis("MoveRight", this, &ACollidingPawn::MoveRight);
PlayerInputComponent->BindAxis("Turn", this, &ACollidingPawn::Turn);

总结一下流程,就是说:

先在继承自Pawn的类里将一系列组件(如弹性Camera,粒子效果,VisualSphere)绑定好;

然后创建继承自PawnMovementComponent的类,这个父类本身就已经定义了很多东西,我们只需在Constructor里用Super继承过来就行,所以上面只添加了一个碰撞后沿碰撞体表面移动的代码;

再回到那个定义了一系列组件的类里,将刚刚创建好的那个继承自PawnMovementComponent的类实例化,并使之更新root Component,之后就可以在定义运动和特效的一些函数里使用这个实例,很方便;

最后,就只需要在SetupPlayerInputComponent里,将Action和Axis两种映射绑定好就行了。

Variables, Timers, and Events

介绍一下如何将变量和函数暴露给Editor,如何用Timer延迟或重复代码运行,如何用Events与Actor交互。

创建一个继承自Actor的类,命名为Countdown。

// 添加到头文件里
#include "Components/TextRenderComponent.h"

UPROPERTY(EditAnywhere)
int32 CountdownTime;
// 可渲染文本组件…………
UTextRenderComponent* CountdownText;
void UpdateTimerDisplay();

// Constructor实现
ACountdown::ACountdown()
{
    // 让这个Actor每帧都响应一下,如果不需要可以关闭
    PrimaryActorTick.bCanEverTick = false;
    
    CountdownText = CreateDefaultSubobject<UTextRenderComponent>(TEXT("CountdownNumber"));
    // 设定需要该component与之对齐的水平线
    CountdownText->SetHorizontalAlignment(EHTA_Center);
    CountdownText->SetWorldSize(150.0f);
    // 就将它作为根组件
    RootComponent = CountdownText;
    // 之前声明的是int32嘛,这里应该是没必要设置成float
    CountdownTime = 3;
}
// 实现UpdateTimeDisplay,设置需要显示出来的Text,这里设置的是剩下多少秒
void ACountdown::UpdateTimeDisplay()
{
	CountdownText->SetText(FString::FromInt(FMath::Max(CountdownTime, 0)));
}

// 当一个函数需要定时器Timer时,我们首先在.h文件中定义定时器句柄TimeHandle----也就是定义一个定时器手柄,用来控制Timer,在头文件里声明下面的东西
FTimerHandle CountdownTimerHandle; // 声明的TimeHandle
void AdvanceTimer();
// 回顾一下,BlueprintNativeEvent,除了可以让蓝图调用此c++函数之外,就是函数在c++里声明并实现的时候,它可以在蓝图里再实现一份,这样两边的函数都会在被调用时生效执行,但这需要你在c++里声明该函数后,不用实现它,而是另外声明并实现它相应的一个虚函数,该虚函数的函数名加个后缀_Implementation(),并在蓝图中的Event [FunctionName]节点后插进一个Parent:[FunctionName]节点,方法是右键Event [FunctionName]节点选择Add call to parent function
UFUNCTION(BlueprintNativeEvent)
void CountdownHasFinished();
virtual void CountdownHasFinished_Implementation();


// 实现它们
void ACountdown::AdvanceTimer()
{
    --CountdownTime;
    UpdateTimerDisplay();
    if (CountdownTime < 1)
    {
        // 倒计时完毕,让Timer停下来
        GetWorldTimerManager().ClearTimer(CountdownTimerHandle);
        CountdownHasFinished();
    }
}
void ACountdown::CountdownHasFinished_Implementation()
{
    CountdownText->SetText(TEXT("GO!"));
}

// 在BeginPlay()中添加一下代码,GetWorldTimerManager()需要在BeginPlay() 中调用,在构造函数中叫他人家会崩溃
UpdateTimerDisplay();
GetWorldTimerManager().SetTimer(CountdownTimerHandle, this, &ACountdown::AdvanceTimer, 1.0f, true);
// SetTimer()是这样的,第一个参数得是你所设立的TimerHandle,第二个参数时哪个对象使用这个Timer,第三个是每隔一段时间,这个时间就是第四个参数,会执行的函数,第五个就是第一次执行该函数后,要不要把每隔一段时间执行函数这个操作一直做下去

接下来,把该类直接拖进Viewport中,然后生成一个它的蓝图,在Event Graph里添加节点Event Countdown Has Finished,然后拉出线生成一个叫Spawn Emitter At Location的节点,Emitter是辐射源的意思,用以生成一个粒子效果,只要在左边的pin中将Location拉出来生成一个Get Actor Location节点,使粒子效果在Actor脚下生成,在Emitter Template注脚中选择特效即可,最后就是插入一个Parent节点,让c++里的CountdownHasFinished函数也能生效,方法上面说过了。

另外,可以在后面跟着加上Delay和DestroyActor两个节点来让它显示完后消失。

User Interface With UMG

介绍创建一个简单的Game开始菜单。

首先在我们项目的.Build.cs文件,做以下两个修改,目的是添加一些模块。

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG" });

PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });

// 注意一个时Public,一个时Private

然后,创建继承自GameMode的类,如果启动项目时打开了c++,那它会自动帮你创一个,添加以下成员。

public:
   // 这里竟然直接复制一个实例传过去???,不过这里确实也很特殊,必须在Editor里指定这个NewWidgetClass所代表的UMG资源,不然CreateWidget函数那里会报错
    UFUNCTION(BlueprintCallable, Category = "UMG Game")
    void ChangeMenuWidget(TSubclassOf<UUserWidget> NewWidgetClass);

// Widget,即控件,这一节可以大致理解为菜单

protected:
    // 手动声明此重载函数
    virtual void BeginPlay() override;

    // 作为在Editor里选择的UMG资源的引用
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UMG Game")
    TSubclassOf<UUserWidget> StartingWidgetClass;

    // 在Widget创造后指向它的指针
    UPROPERTY()
    UUserWidget* CurrentWidget;

// 以及引入的头文件
#include "Blueprint/UserWidget.h"
// 下面是实现
void A[ProjectName]GameMode::BeginPlay()
{
    Super::BeginPlay();
    ChangeMenuWidget(StartingWidgetClass);
}
// 如果本来Viewport上有正在显示的widget,那么移除它,并把新的放进去
void A[ProjectName]GameMode::ChangeMenuWidget(TSubclassOf<UUserWidget> NewWidgetClass)
{
    if (CurrentWidget != nullptr)
    {
        CurrentWidget->RemoveFromViewport();
        CurrentWidget = nullptr;
    }
    if (NewWidgetClass != nullptr)
    {
        // 为你在Editor里指定的那个UMG资源给个UUserWidget内存空间并用指针记住它,有一种“正名化为UserWidget”的感觉
        CurrentWidget = CreateWidget<UUserWidget>(GetWorld(), NewWidgetClass);
        if (CurrentWidget != nullptr)
        {
            CurrentWidget->AddToViewport();
        }
    }
}

再创建一个继承自PlayerController的类,随便命个名,如MyPlayerController。

// 声明并实现,这些东西的作用就是让菜单能与鼠标光标互动
public:
    virtual void BeginPlay() override;
    
void AHowTo_UMGPlayerController::BeginPlay()
{
    Super::BeginPlay();
    SetInputMode(FInputModeGameAndUI());
}

现在已经弄完显示和关闭菜单的基本代码框架了,接下来到Editor里弄一下菜单上需要有的一些图标,Editor->Add New->User Interface->Widget Blueprint,创建两个,分别命名为MainMenu和NewGameMenu,分别用作一级菜单和二级菜单,先打开用于一级菜单的蓝图,从左边的Palette面板里将Button和Text拖出来,Text直接放到Button上面,这里不用图片而是用Text直接代表Button上需要显示的内容,调整Button实例和Text实例的细节,如下。

(将Visiblity设为Not Hit-Testable(Self&All Children),上图是老版本的,可以防止文本块阻碍鼠标点击下方按钮)

然后像上面一样,创建一个用于退出菜单的按钮,同样拖出Button和Text,Button实例命名为QuitButton,Text命名为QuitText,其具体显示文本命名为Quit,前者位置设置为 (-200, -400),大小为(200, 100),后者直接放到前者上面,会自动粘合。

接下来在NewGameButton和QuitButton的细节面板的Events里添加OnClicked事件,如下设置蓝图节点,这里就调用了之前设置的c++函数。

(这里ChangeMenuWidget节点的NewWidgetClass参数用的就是我们创建的这个叫NewGameMenu的UserWidget蓝图类,这也就是为之前代码部分头文件里声明的那个TSubclassOf<UUserWidget>成员变量指定一个用来切换的UMG资源,但因为NewGameMenu蓝图还是空的,这里如果就Play的话,点击New Game按钮会无效)

往后,我们创建一个继承自我们创建的[ProjectName]GameMode类的蓝图类,命名为MenuGameMode,再创建一个继承自PlayerController类(就在常用类里)的蓝图类,命名为MenuPlayerController。

打开MenuPlayerController,在细节面板里的Mouse Interface里,打开Show Mouse Cusor选项。

打开MenuGameMode,看向细节面板,StatingWidgetClass必须设置为我们之前创建的那个叫NewGameMenu的UserWidget蓝图类;Classes下的PlayerControllerClass需设置为上面创建的MenuPlayerController,这样打开菜单时才有鼠标的光标,DefaultPawnClass需设置为Pawn,不然在打开菜单的时候Player他可能会跟着鼠标或键盘乱飞。

现在到WorldSetting里,将GameModeOverride改为我们的MenuGameMode蓝图类,不要用成创建的C++类,这个MenuGameMode毕竟是它的子类,功能在此完善,这样,简单的一级菜单构建完成

下面创建第二级菜单,含有输入名字的窗口,返回一级菜单的按钮,以及不输入名字就按不了的Play按钮。

同样打开我们用作二级菜单的蓝图类NewGameMenu,拖出TextBox(命名为NameTextEntry),两个Button(分别命名为PlayGameButton和MainMenuButton),和相应的Text,和上面的步骤一样,再把一些东西补齐,位置大小如图,大致即可。

需要补充的是,TextBox即NameTextEntry的细节面板里,Style->Front->Size需设置为20,这是再运行时你输入进这个框里的字体大小,对应上图就是那个J.J.,另外,那些在Button上的Text,与之前一样,需要将Visiblity设为Not Hit-Testable(Self&All Children)。

来到PlayGameButton,在它的细节面板里,Behavior->IsEnabled->Bind->CreateBinding,这里需要注意有可能弄来弄去这里或者别的Button会弄成None,那就需要Remove它变成原来只显示Bind的情形,不然蓝图会报错,而且你不是知道错哪,就是这个选项不能显示为None,选项模样如下。

CreateBinding后会打开蓝图,这里的逻辑就是获取NameTextEntry那个TextBox的String长度,若大于0,则该按键按下去有效,如图。

然后再到MainMenuButton的细节面板里,Slot->Anchors,选择方块在右下角的那个,如图。这个玩意儿就是在你有设置菜单弹出的效果时,从哪边弹出。

最后为两个Button添加Click事件,和之前一样,就是PlayGameButton的蓝图中,最后的ChangeMenuWidget节点无需选择NewWidgetClass,这样在运行时点击它后,菜单就会全部消失,如图。

Player-Controlled Cameras

其实这一小节应该放在ProgrammingBasic这一节的第二个说的,无奈之后才注意到有这一小节…………不过现在也好懂,就当再熟悉一遍流程。

将摄像头和控制的Pawn绑定起来,创建一个继承自Pawn的c++类,命名为PawnWithCamera。

// 在头文件里声明一下成员变量
protected:
    UPROPERTY(EditAnywhere)
    class USpringArmComponent* SpringArmComp;

    UPROPERTY(EditAnywhere)
    class UCameraComponent* CameraComp;

    UPROPERTY(EditAnywhere)
    UStaticMeshComponent* StaticMeshComp;

// 在源文件里添加以下头文件
#include "GameFramework/SpringArmComponent.h"
#include "Camera/Component.h"

// 在Constructor里添加以下代码,现在应该好懂很多了
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootSceneComponent"));
StaticMeshComp = CreateDefaultSubobject <UStaticMeshComponent>(TEXT("StaticMeshComponent"));
SpringArmComp = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArmComponent"));
CameraComp = CreateDefaultSubobject<UCameraComponent>(TEXT("CameraComponent"));

StaticMeshComp->SetupAttachment(RootComponent);
SpringArmComp->SetupAttachment(StaticMeshComp);
CameraComp->SetupAttachment(SpringArmComp,USpringArmComponent::SocketName);

SpringArmComp->SetRelativeLocationAndRotation(FVector(0.0f, 0.0f, 50.0f), FRotator(-60.0f, 0.0f, 0.0f));
SpringArmComp->TargetArmLength = 400.f;
SpringArmComp->bEnableCameraLag = true;
SpringArmComp->CameraLagSpeed = 3.0f;

// 别忘了这样行,Player0控制Pawn
AutoPossessPlayer = EAutoReceiveInput::Player0;

到Editor里来设置一下,如图。

// 声明以下成员变量和函数,说一下,就是看bZoomingIn是否为真,让ZoomFactor增加Camera可以看到的范围和SpringArm的长度,后者相当于让Camera的反应更慢,把镜头拉近肯定不能让摄像头和原来一样快吧
FVector2D MovementInput;
FVector2D CameraInput;
float ZoomFactor;
bool bZoomingIn;

void MoveForward(float AxisValue);
void MoveRight(float AxisValue);
void PitchCamera(float AxisValue);
void YawCamera(float AxisValue);
void ZoomIn();
void ZoomOut();

// 实现
//Input functions
void APawnWithCamera::MoveForward(float AxisValue)
{
    MovementInput.X = FMath::Clamp<float>(AxisValue, -1.0f, 1.0f);
}

void APawnWithCamera::MoveRight(float AxisValue)
{
    MovementInput.Y = FMath::Clamp<float>(AxisValue, -1.0f, 1.0f);
}

void APawnWithCamera::PitchCamera(float AxisValue)
{
    CameraInput.Y = AxisValue;
}

void APawnWithCamera::YawCamera(float AxisValue)
{
    CameraInput.X = AxisValue;
}

void APawnWithCamera::ZoomIn()
{
    bZoomingIn = true;
}

void APawnWithCamera::ZoomOut()
{
    bZoomingIn = false;
}

// 在SetupPlayerInputComponent()函数里将按键信息与声明的函数中接受的参数绑定
InputComponent->BindAction("ZoomIn", IE_Pressed, this, &APawnWithCamera::ZoomIn);
InputComponent->BindAction("ZoomIn", IE_Released, this, &APawnWithCamera::ZoomOut);

InputComponent->BindAxis("MoveForward", this, &APawnWithCamera::MoveForward);
InputComponent->BindAxis("MoveRight", this, &APawnWithCamera::MoveRight);
InputComponent->BindAxis("CameraPitch", this, &APawnWithCamera::PitchCamera);
InputComponent->BindAxis("CameraYaw", this, &APawnWithCamera::YawCamera);

// 在Tick()函数里做最后工作

// 按下与bZoomIn绑定的按键时进行放大,放开恢复正常
{
    if (bZoomingIn)
    {
        ZoomFactor += DeltaTime / 0.5f;
    }
    else
    {
        ZoomFactor -= DeltaTime / 0.25f;
    }
    ZoomFactor = FMath::Clamp<float>(ZoomFactor, 0.0f, 1.0f); // 限制

    // 这里再解释一下Lerp插值函数,Lerp(a, b, weight) = a + (b - a) * weight,weight越小,则结果越倾向于a,越大则越倾向于b,这个函数就是一个平缓过渡的作用,根据weight的大小在a和b之间选择值,更直观一点可以看这位博主的讲解:zhuanlan.zhihu.com/p/114898567
    CameraComp->FieldOfView = FMath::Lerp<float>(90.0f, 60.0f, ZoomFactor);
    SpringArmComp->TargetArmLength = FMath::Lerp<float>(400.0f, 300.0f, ZoomFactor);
}
// 把Pawn和Camera绑定起来,Camera转Pawn也跟着转
{
    FRotator NewRotation = GetActorRotation();
    NewRotation.Yaw += CameraInput.X;
    SetActorRotation(NewRotation);
}
// 限制Camera能转的角度,让其始终俯视Pawn
{
    FRotator NewRotation = OurCameraSpringArm->GetComponentRotation();
    NewRotation.Pitch = FMath::Clamp(NewRotation.Pitch + CameraInput.Y, -80.0f, -15.0f);
    SpringArmComp->SetWorldRotation(NewRotation);
}
// 处理移动了
{
    if (!MovementInput.IsZero())
    {
        // 把移动的输入值放大100倍,就是让它移动速度看起来正常一点
        MovementInput = MovementInput.SafeNormal() * 100.0f;
        FVector NewLocation = GetActorLocation();
        NewLocation += GetActorForwardVector() * MovementInput.X * DeltaTime;
        NewLocation += GetActorRightVector() * MovementInput.Y * DeltaTime;
        SetActorLocation(NewLocation);
    }
}

Referencing Assets

首先介绍UE中的资源是什么,就是在工程文件夹下的那些非代码文件,如网格体,材质,蓝图(对,蓝图也是)等这些文件,大部分资源是以uasset作为后缀的,当然也有其他后缀如地图关卡的umap。

在打包时,这些文件可能会根据平台需要,被cook成更小的平台专用文件,然后被放在后缀是pak的压缩包里。游戏运行时,程序就会挂载解压这些pak包,然后加载包中的资源文件来使用。

而所有资源文件,可将其看作序列化到文件中的数据,程序在加载资源时,将文件中的这些数据反序列化为UObject或其它可以用的内存对象。

通俗且简化来讲,加载资源,就是那个资源被放进内存里去了。

而整个资源的加载与卸载,就是与之相应的UObject的创建与销毁。

回到正题,UE4提供两种引用方式,HardReference和SoftReference,打两个比方,前者就是,A引用B,一开始就给A指定好了引用B,加载A时就会加载B,即一开始就加载全部资源;而后者是通过一种间接机制(如String Path)来使得A引用B,让A延后加载或是在运行时再指定引用的资源是B并加载B,即可以灵活加载资源。下面前两小节先说HardReference,后两小节再说SoftReference。

另外提一下,Editor里可以通过右键各种资源(或其它方式)来打开ReferenceViewer,查看引用关系。

Direct Property Reference

直接通过属性引用资源,最常用的资源引用方式就是通过UPROPERTY宏暴露给Editor,让designer可以通过继承该c++类的蓝图类或把该c++类直接丢进World里来指定引用资源,打个非常浅显的比方,就是UPROPERTY宏修饰的一个类指针,你可以在Editor里通过上两种方式在细节面板里为该指针指定一个资源,就是引用的资源,这种指定的操作就是修改类属性嘛。每当这个继承的蓝图类或World的Actor加载时,就会自动加载该UPROPERTY宏引用的资源。

/** construction start sound stinger */

UPROPERTY(EditDefaultsOnly, Category=Building)

USoundCue* ConstructionStartStinger;

Construction Time Reference

构造时就指定引用资源,需要用到我们之前见到多次的类ConstructorHelper,用法如下。

// 这段代码我们之前用过
static ConstructorHelpers::FObjectFinder<UStaticMesh> SphereVisualAsset(TEXT("/Game/StarterContent/Shapes/Shape_Sphere.Shape_Sphere"));
if (SphereVisualAsset.Succeeded())
{
    SphereVisual->SetStaticMesh(SphereVisualAsset.Object);
    SphereVisual->SetRelativeLocation(FVector(0.0f, 0.0f, -40.0f));
    SphereVisual->SetWorldScale3D(FVector(0.8f));
}

// 这是文档里该小节的例子
/** gray health bar texture */

UPROPERTY()

class UTexture2D* BarFillTexture;

AStrategyHUD::AStrategyHUD(const FObjectInitializer& ObjectInitializer) :
    Super(ObjectInitializer)
{
    static ConstructorHelpers::FObjectFinder<UTexture2D> BarFillObj(TEXT("/Game/UI/HUD/BarFill"));
    BarFillTexture = BarFillObj.Object;

}

但有一点,如果ConstructorHelper没找到路径里的东西,那么会设置为nullptr,那样加载该资源时会崩溃,所以得判断一下,最好当然是用assert功能的Check,Verify,Ensure三种体系里的一种方法来判断。

上面两种HardReference工作方式其实相同,不同的时初试指定资源方式不同。

另外,关于HardReference,当类对象被加载时,其所引用的所有资源也会被加载,那么同一时间加载过多资源可能会引起问题,这时可以通过搭配下面两种SoftReference,来推迟这种加载或是在运行时指定引用资源并加载。

(说实话下面这两小节我没搞太懂,找资料找了一个下午,等知识积累到一定程度再回来看看,做个记号???)

Indirect Property Reference

控制何时加载引用资源,这里先把例子放上来,好理解。

UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category=Building)
TSoftObjectPtr<UStaticMesh> BaseMesh;

UStaticMesh* GetLazyLoadedMesh()
{
    if (BaseMesh.IsPending()) // 是否可以被访问并加载,即是否已经被加载了,没有被加载,返回true
    {
        const FSoftObjectPath& AssetRef = BaseMesh.ToStringReference();
        BaseMesh = Cast< UStaticMesh>(Streamable.SynchronousLoad(AssetRef));
    }
    return BaseMesh.Get();
}

主角是TSoftObjectPtr<>模板类,对于在Editor里操作的designer来说,它与前面的HardReference没啥区别,同样需要在Editor里指定引用资源。

在代码层面,它不是指针,而是将指定资源的那个属性以一种String的形式与模板类代码存储在一起,它的主要作用是安全地判断那个指定好的资源是否已经被加载。

其所属方法IsPending()可以判断资源是否已经被加载,在上面的例子中,如果没有被加载,会使用执行同步加载,并返回相应的指针,若加载了,那就返回加载了的那个资源的地址。

记住使用TSoftObjectPtr<>需要手动加载资源,加载资源的方法举出三种,templated LoadObject<>() method, StaticLoadObject(), or the FStreamingManager,方法具体看下一节Asynchronous Asset Loading 。

Find/Load Object

上面说的几种引用方式都基于UPROPERTY宏,但如果我们想在运行时而非构造时用String为对象指定引用资源的话,有两种方法:如果资源已经加载完毕,用FindObject<>(),没加载,用LoadObject<>()

// 示例
// FindObject<>()的第二个参数是资源路径,第一个是Outer,一般填this
AFunctionalTest* TestToRun = FindObject<AFunctionalTest>(TestsOuter, *TestName);
// LoadObject<>()
GridTexture = LoadObject<UTexture2D>(NULL, TEXT("/Engine/EngineMaterials/DefaultWhiteGrid.DefaultWhiteGrid"), NULL, LOAD_None, NULL);

(解释一下Outer是什么,就是父对象,之前说过,资源的加载与卸载就是相应的UObject的创建与销毁,一个对象的Outer有且仅有一个但子对象可以有很多,就比如Components的Outer是Actor)

还有一种简化版的LoadObject<>() ,即LoadClass<>(),可以自动验证类型。

DefaultPreviewPawnClass = LoadClass<APawn>(NULL, *PreviewPawnName, NULL, LOAD_None, NULL);

// 相当于下面

DefaultPreviewPawnClass = LoadObject<UClass>(NULL, *PreviewPawnName, NULL, LOAD_None, NULL);

if (!DefaultPreviewPawnClass->IsChildOf(APawn::StaticClass()))
{
    DefaultPreviewPawnClass = nullptr;
}

Asynchronous Asset Loadin

Asynchronous,不同时存在。

介绍在运行时加载和卸载的方法。

posted @   coridal-12  阅读(513)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示