(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
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不会被当作垃圾回收的条件有三种:
- UObject对象被加入到root set上(调用AddRoot函数)。
- 直接或者间接被root set 里的对象引用(如UPROPERTY宏修饰的UObject成员变量 注:UObject放在UPROPERTY宏修饰的TArray、TMap中也可以)
- 直接或间接被存活的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 integerint16
/uint16
: 16-bit signed/unsigned integerint32
/uint32
: 32-bit signed/unsigned integerint64
/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的三种等价体系, check
,verify
, ensure
,三个有细微差别,但主要作用相同,都声明于 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的组件。

有两种映射输入的类型: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,不同时存在。
介绍在运行时加载和卸载的方法。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!