这一章,我们要实现一个俯视视角的坦克小游戏,玩家可以操作坦克在地图中移动。敌人是固定的,但是具备一定的AI,可以瞄准玩家,并且在玩家进入攻击范围后,对玩家进行攻击。如果玩家被摧毁,则游戏结束。反之,玩家需要摧毁所有的敌人后,达成游戏胜利的条件。
一、前言介绍
在开始之前,我们先介绍这一章会涉及到的内容:
- 创建一个坦克,可以在地图中自由移动
- 解决输入问题(WASD移动,鼠标点击射击,鼠标转动明确攻击方向)
- 创建一个敌方炮台类
- 添加开火功能,玩家和炮台都可以开火攻击敌人
- 添加血条、伤害和破坏效果
- 添加HUD的胜利和失败界面
关于绑定输入,这里就不多赘述,如下图:
关于素材和地图,这里也不是我们的重点,暂时略过,感兴趣的可以自行寻找。我们直接进入编码步骤。
二、创建BasePawn
1.1?创建BasePawn
由于我们这里会有两个pawn(玩家和敌人),我们可以先创建一个BasePawn类。这将具有坦克和炮塔共享的基本功能。然后我们就可以创建我们的两个子类,炮台和坦克。
我们新建一个C++类:
?但是哪一个才最适合我们想要做的事情?
- Actor类,可以被放置在世界中,有相应的视觉表现
- Pawn类,可由控制器拥有,可以处理运动输入
- Character类,有一些特定于角色的东西,适合双腿的运动模式或类似飞行和游泳运动。
所以,Pawn应该是我们想要的。我们创建对应的C++类即可。
1.2 Component
USceneComponent:
- has a transform(旋转或位置)
- supports attachment(这意味着我们可以将其他组件附加到场景组件)
- no visual representation
UCapsuleComponent:
UStaticMeshComponent:
我们讨论组件的目的,是要理清之后的操作思路。我们首先知道我们的Pawn有自己的root component,它的类型是USceneComponent。我们知道他是没有visual representation,是不可见的。
但是我们可以使用其他类型从SceneComponent(场景组件)派生的对象重新assign该根组件。我们知道UCapsuleComponent来自SceneComponent。如果我们创建一个Capsule,我们可以assign这个,作为Root,替换默认的SceneComponent:
RootComponent(UCapsuleComponent) = CapsuleComp(UCapsuleComponent)
具体的思路可以见下图:
在蓝图中,我们创建一个Actor蓝图,然后添加Capsule,接着添加静态网格体组件,并选择车身网格体,同理添加炮台。
?结果:
?在C++中,我们先在.h中定义
private:
UPROPERTY()
class UCapsuleComponent* CapsuleComp;
然后在.cpp中添加头文件,并将其设置为Root
CapsuleComp = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule Collider"));
RootComponent = CapsuleComp;
这样我们将BasePawn拖入场景中时,会有:
?接下来我们要将BaseMesh和TurretMesh,attach到Root上(和之前的操作相同):
UPROPERTY()
UStaticMeshComponent* BaseMesh;
UPROPERTY()
UStaticMeshComponent* TurretMesh;
UPROPERTY()
USceneComponent* ProjectileSpawnPoint;
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Base Mesh"));
BaseMesh->SetupAttachment(CapsuleComp);
TurretMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Turret Mesh"));
TurretMesh->SetupAttachment(BaseMesh);
ProjectileSpawnPoint = CreateDefaultSubobject<USceneComponent>(TEXT("Spawn Point"));
ProjectileSpawnPoint->SetupAttachment(TurretMesh);
接下来我们可以为BasePawn创建一个基于BasePawn的蓝图类,这样我们进入后,可以看见这样的组件结构:
?然后我们还要创建一个蓝图类作为敌人,命名为BP_PawnTurret。
1.3 UPROPERTY
但是有一点要注意的是,当我们选中左边的组件时,右边的细节面板内容不见了。这是因为 C++ 方面的蓝图没有任何内容。我们可以通过UPROPERTY来对齐进行操作。
UPROPERTY Specifiers:
| Defaults | Instance | Event Graph(l蓝图的事件图表) | Read Only | VisibleAnyWhere | BluePrintReadOnly | VisibleDefaultsOnly | VisibleInstanceOnly | Read/Write(set) | EditAnyWhere | BluePrintReadWrite | EditDefaultOnly | EditInstanceOnly |
关于事件图表,我们在C++中可以使用:
UPROPERTY(VisibleAnyWhere,BluePrintReadWrite)
int32 visibleAnywhere = 12;
这样我们在事件图表中搜索visibleAnywhere,就会有两个节点Set和Get。
其次还有一个需要注意的点是,对于private,会有报错(同样对于BluePrintReadOnly):
BluePrintReadWrite should not be used on private members
对于这种情况,我们仍然可以在事件图表中访问私有变量,我们需要添加:
UPROPERTY(VisibleAnyWhere,BluePrintReadWrite,meta = (AllowPrivateAccess = "true"))
当然我们可以为其添加Category,这个在之前有提到过。
在这一步之后,别忘了给坦克和炮台添加网格体,移动project point位置。
二 Moving Tank
2.1 Component
要实现坦克的移动和玩家输入,炮台的站立,我们要创建新的派生类:
其次,为了实现坦克的移动,我们首先要保证视角跟随,即坦克的身后有一个摄像机——Camera Component(UCameraComponent)和一个固定距离的Spring arm component(USpingArmComponent)。
在蓝图中:
所以我们回到蓝图中,添加弹簧臂组件到Capsule下:
?然后我们选中弹簧臂组件,再添加一个摄像机组件:
但是我们的主要目标是C++。
在C++中:
我们在.h中创建:
public:
ATank();
private:
UPROPERTY(VisibleAnyWhere,Category = "Component")
class UCameraComponent* Camera;
UPROPERTY(VisibleAnyWhere,Category = "Component")
class USpringArmComponent* SpringArm;
在.cpp中:
ATank::ATank(){
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("Spring Arm"));
SpringArm->SetupAttachment(RootComponent);
Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
Camera->SetupAttachment(SpringArm);
}
然后在蓝图中,可以在类设置修改父类:
?这样我们就可以看到刚刚设置的组件:
?还有一个需要注意的是,我们进入游戏后,场景中哪个是我们要操控的?我们可以选中坦克,然后分配player0:
?这样在游戏开始后,我们操控的就是坦克。
2.2 处理输入
2.2.1 Bind Axis Mapping
我们需要在游戏的每帧,获得按键的输入。
我们把BasePawn中的SetupPlayerInputComponent函数移动到Tank中,并且我们定义一个Move函数 ?
void Move(float value);
在之前的函数中,加入一句:
PlayerInputComponent->BindAxis(TEXT("MoveForward"),this,&ATank::Move);
然后我们在Move函数中,添加测试的输出:
UE_LOG(LogTemp,Warning,TEXT("The Value is %f"),value);
这样在游戏中的输出日志中,我们可以看到按下W和S的value值(1和-1)。证明我们Bind成功。
2.2.2 Adding the Offset
要想在引擎中移动,就要对其添加位移。但是这里要弄清楚,我们是在Local space还是在World Space。
我们在场景中选中的Actor,有它们自己的Local方向,但是这个不一定和World方向相同。所以我们希望,当按下W键时,坦克能按照它的Local方向进行前进。
我们将使用AddActorLocalOffset来完成这件事情。我们转到它的定义位置:
void AActor::AddActorLocalOffset(FVector DeltaLocation, bool bSweep, FHitResult* OutSweepHitResult, ETeleportType Teleport)
{
if(RootComponent)
{
RootComponent->AddLocalOffset(DeltaLocation, bSweep, OutSweepHitResult, Teleport);
}
else if (OutSweepHitResult)
{
*OutSweepHitResult = FHitResult();
}
}
我们可以看到它会检查RootComponent是否为Null,所以这里实际做的是对RootComponent添加位移。
我们可以在BasePawn里进行测试(别忘了测试完成后删除):
void ABasePawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
FVector DeltLocation(0.f);
DeltLocation.X = 2.f;
AddActorLocalOffset(DeltLocation);
}
我们在虚幻中进行模拟,可以看到所有的坦克和炮台都朝着自己的X方向进行移动。既然了解了怎么移动,我们可以在Move函数中,进行操作:
FVector DeltLocation = FVector::ZeroVector;
DeltLocation.X = value;
AddActorLocalOffset(DeltLocation);
这样在场景中,我们可以按住W和S控制前进和后退。
2.2.3 Speed?
关于这个的修改,我们在之前的蓝图学习中有提到过,要使用Delta time。
我们先定义一个可以调整的速度参数:
UPROPERTY(EditAnyWhere,Category ="Movement")
float Speed = 200.f;
然后再利用Delta time来做Scale。
DeltLocation.X = value * Speed * UGameplayStatics::GetWorldDeltaSeconds(this);
这样之后也可以在蓝图中调整速度。
2.3 Local Rotation
我们先来介绍一下Sweeping:
在游戏编程中,sweeping是引擎完成的一种技术,只要它处于开启。如果我们有一个移动的物体,比如有一个圆代表的一个球体,当这个球体移动时,每一帧,引擎都会执行sweep?检查。
这意味着它正在检查这个特定帧的移动是否会导致两个对象之间的重叠。
假设某一帧,一个球体和长方体重合了。那么此时Sweeping启用的功能是,引擎将检测到这种重叠并将该球体移回该特定帧,以便它永远不会真正穿透对象。
在官方文档中:
Whether we sweep to the destination location, triggering overlaps along the way and stopping short of the target if blocked by something. Only the root component is swept and checked for blocking collision, child components move without sweeping. If collision is off, this has no effect.
注意最后一句,所以要保证碰撞enabled,Sweeping才能正常工作。
所以我们现在代码中加入:
AddActorLocalOffset(DeltLocation,true);
然后在蓝图设置中将碰撞预设修改为BlockAllDynamic。
这样Sweeping这项功能设置完成。
而添加旋转的方式和之前的类似:
void ATank::Turn(float value){
FRotator DeltaRotation = FRotator::ZeroRotator;
DeltaRotation.Yaw = value * TurnRate * UGameplayStatics::GetWorldDeltaSeconds(this);
AddActorLocalRotation(DeltaRotation,true);
}
但是这样只是完成了整个坦克的旋转,对于坦克的上半身我们没有添加旋转,而这个需要用到鼠标的输入。
我们希望坦克的上身能跟随鼠标的方向进行转动,我们可以得到鼠标的位置,如果我们要从相机直接向光标画一条线并击中世界中的某个物体,我们可以获得该位置并使用该位置来设置坦克炮塔的旋转。
具体应该怎么做,我们还需要了解Casting。
只要对象本身是我们试图转换为的类型,casting?就会将一个指针的类型cast为另一种指针类型。
我们在BeginPlay中加入代码:
PlayerControllerRef = Cast<APlayerController>(GetController());
这样我们获得了获得了对Controller的访问。并且用cast函数从Acontroller* 到APlayerController*。
我们再整理一下思路:
?我们要完成这个操作在每一帧,所以我们需要Tick函数。然后在函数中:
Super::Tick(DeltaTime);
if (PlayerControllerRef)
{
FHitResult HitResult;
PlayerControllerRef->GetHitResultUnderCursor(
ECollisionChannel::ECC_Visibility,
false,
HitResult);
}
在我们call这个函数后,HitResult会被line trace的数据填充。我们可以获得碰撞事件的point等。
我们现在可以画一个debugsphere来进行测试:
DrawDebugSphere(GetWorld(),
HitResult.ImpactPoint,
25.f,
12,
FColor::Red,
false,
-1.f
);
}
我们到引擎中编译,可以看到我们鼠标的位置会有一个球体,准确的说是在鼠标连线之间的物体碰撞位置(仅供测试):
现在我们可以尝试利用鼠标控制炮台的旋转了。关于向量的计算,这里就不多介绍了。
需要注意的一点是,如果我们的鼠标在地面上,会导致炮台不是水平方向上的旋转,会指向地面。这个效果不是我们想要的,我们希望它能水平,也就是说,我们只希望它旋转的部分数值。
我们将代码放置在BasePawn中,这样后续敌方炮台也可以使用。
protected:
//任何放置在这里的函数或变量,只能被它的子类访问。
void RotateTurret(FVector LookAtTarget);
void ABasePawn::RotateTurret(FVector LookAtTarget){
FVector ToTarget = LookAtTarget - TurretMesh->GetComponentLocation();
FRotator LookAtRotation = FRotator(0.f,ToTarget.Rotation().Yaw,0.f);
TurretMesh->SetWorldRotation(LookAtRotation);
}
然后我们回到Tank中,添加。
RotateTurret(HitResult.ImpactPoint);
现在我们可以移动鼠标来让炮台转动了(转出残影):
?接下来,我们还要保证敌人炮台的转动,让它们一直瞄准坦克(需要对Tick函数overwrite)。
我们先从BasePawn创建一个派生类Tower。
在.h中:
public:
virtual void Tick(float DeltaTime) override;
protected:
virtual void BeginPlay() override;
private:
class ATank* Tank;
UPROPERTY(EditDefaultsOnly,Category = "Combat")
float FireRange = 300.f;
.cpp中:
void ATower::Tick(float DeltaTime){
Super::Tick(DeltaTime);
//当坦克进入范围后,才进行瞄准
//做法:找到坦克的Location,找到炮台的Location,利用FVector::Dist()计算距离。
if (Tank)
{
float Distance = FVector::Dist(GetActorLocation(),Tank->GetActorLocation());
if (Distance <= FireRange)
{
//转动炮台
RotateTurret(Tank->GetActorLocation());
}
}
//查看Tank是否在距离内
}
void ATower::BeginPlay(){
Super::BeginPlay();
//获得Tank
Tank = Cast<ATank>(UGameplayStatics::GetPlayerPawn(this, 0));
}
我们可以在蓝图中,将炮台的父类设置为Tower,然后修改Range参数。
三、Fire
3.1 Bind Action Mapping
Axis Mapping的触发像Tick函数,在每一帧触发。传入float值,根据按下的按钮而改变。
而Action Mapping不同,绑定到Action Mapping的回调函数不需要输入参数,所有发生的事情都是在您按下按钮时。不会在每帧触发,只有当按钮按下时触发。
我们绑定Action Mapping和函数,使用:BindAction()。
为了方便后续坦克和敌人的使用,我们在BasePawn里定义Fire函数。
然后我们先在Tank.cpp中添加绑定:
PlayerInputComponent->BindAction(TEXT("Fire"),IE_Pressed,this,&ATank::Fire);
然后对Fire函数,加入测试:
FVector ProjectileSpawnPointLocation = ProjectileSpawnPoint->GetComponentLocation();
DrawDebugSphere(
GetWorld(),
ProjectileSpawnPointLocation,
25.f,
12,
FColor::Red,
false,
3.f
);
我们进入编译查看结果,在我们鼠标点击的位置会有测试结果:
?接下来我们把它也应用到Tower中。我们这里会用到Timers。
.h中:
//2秒的等待时间
//查看我们是否可以开火
FTimerHandle FireRateTimerHandle;
float FireRate = 2.f;
void CheckFireCondition();
.cpp中,首先在BeginPlay中:
GetWorldTimerManger().SetTimer(FireRateTimerHandle,this,&ATower::CheckFireCondition,FireRate,true);
void ATower::CheckFireCondition(){
if (Tank)
{
float Distance = FVector::Dist(GetActorLocation(),Tank->GetActorLocation());
if (Distance <= FireRange)
{
//转动炮台
Fire();
}
}
}
我们测试结果:
?可以看到,当我们靠近炮台时,Fire函数被使用。关于重构的部分暂时略过。
3.2 发射子弹
我们创建一个Projectile Class,过程和之前类似(Actor),这里简单说一下:
创建Projectile Class,创建UStaticMeshComponent,并设置为RootComponent,创建基于它的蓝图,设置网格体。
接下来我们就要在场景中生成(spawn)子弹,这需要SpawnActor函数。
在这之前,我们先学习TSubclassOf:
TSubclassOfhttp://tsubclassof/在BasePawn中添加
UPROPERTY(EditDefaultsOnly,Category = "Combat")
TSubclassOf<class AProjectile> ProjectileClass;
然后回到BP_PawnTank蓝图中,右边的细节面板就有了选项:
我们选择蓝图,是因为TSubClassof允许我们设置这个Projectile class为一个特定的type,基于Projectile。这样选择之后,Projectile class被设置为BP?Projectile 类型。? ? ? ??
那为什么要设置这个?我们要了解SpawnActor的怎样工作的。
SpawnActor是属于UWorld class的函数,Spawn actor 可以在游戏运行时在运行时调用,它可以创建 actor。
SpawnActor:关于SpawnActor<>(),如果我们想生成子弹,我们需要首先传入C++ class type在<>里Aprojectile。
我们在BasePawn中的fire函数生成子弹:
void ABasePawn::Fire(){
FVector Location = ProjectileSpawnPoint->GetComponentLocation();
FRotator Rotation = ProjectileSpawnPoint->GetComponentRotation();
GetWorld()->SpawnActor<AProjectile>(ProjectileClass,Location,Rotation);
}
进入编译器查看结果:
?但是子弹没有移动,我们接下来设置子弹的移动。想要完成子弹的移动有几个方法:
- 1 设置子弹的方向和距离,这需要每帧更新
- 2 添加Impulse,引擎进行物理模拟
- 3 使用MoveMent组件
我们使用Projectile Movement Component:UProjectileMovementComponenthttps://docs.unrealengine.com/4.27/en-US/API/Runtime/Engine/GameFramework/UProjectileMovementComponent/
我们添加组件斌,并且对炮台也分配子弹网格体:
UPROPERTY(VisibleAnyWhere,Category = "MoveMent")
class UProjectileMovementComponent* ProjectileMoveMent;
ProjectileMoveMent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("Projectile Movement Component"));
ProjectileMoveMent->MaxSpeed = 1300.f;
ProjectileMoveMent->InitialSpeed = 1300.f;
进入编译器,现在我们和炮台都可以发射子弹了:
四 Damage
4.1 Hit Event
首先我们要确定碰撞事件,即子弹的碰撞事件,这样我们可以摧毁子弹。
在.h中
UFUNCTION()
void OnHit(
UPrimitiveComponent* HitComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
FVector NormalImpulse,
const FHitResult& Hit
);
在BeginPlay中:
ProjectileMesh->OnComponentHit.AddDynamic(this, &AProjectile::OnHit);
关于delegates(代理委托): 虚幻4:代理委托基础(delegate) - 知乎
https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/ProgrammingWithCPP/UnrealArchitecture/Delegates/
关于AddDynamic:
it's a macro usually used to bind a function to an event
https://docs.unrealengine.com/4.26/zh-CN/ProgrammingAndScripting/ProgrammingWithCPP/UnrealArchitecture/Delegates/Dynamic/
void AProjectile::OnHit(
UPrimitiveComponent* HitComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
FVector NormalImpulse,
const FHitResult& Hit
){
UE_LOG(LogTemp, Warning, TEXT("OnHit"));
UE_LOG(LogTemp, Warning, TEXT("HitComp: %s"), *HitComp->GetName());
UE_LOG(LogTemp, Warning, TEXT("OtherActor: %s"), *OtherActor->GetName());
UE_LOG(LogTemp, Warning, TEXT("OtherComp: %s"), *OtherComp->GetName());
}
我们在输出日志中,输出碰撞事件的结果:
4.2 Damage/Health Class
既然要造成伤害,那就需要血条或生命值。我们在之前了解过USceneComponent,它derived from UActorComponent:
- UActorComponent:No transform,No attachment
- USceneComponent:Has transform,Support attachment
由于我们要处理伤害和健康,我们不需要多余的东西,UActorComponent足够了。
了解了这些,我们新建Actor组件的C++类——HealthComponent。在里面定义一些简单的变量:
private:
UPROPERY(EditAnywhere)
float MaxHealth = 100.f;
float Health=0.f;
在BeginPlay中
Health = MaxHealth;
然后我们在坦克和炮台的蓝图中加入Health组件。
接下来:
UFUNCTION()
//因为我们要将其bind到delegate,它需要正确的输入参数适合那个delegate。对于要bind到OntakeAnyDamage的函数,输入参数列表如下:
//受到伤害的Actor,伤害数值
//我们有这个输入参数的原因是虚幻引擎有DamageType的概念。我们可以创建具有额外数据的自定义伤害类型,这些数据可以通知你执行不同的操作,具体取决于你可能具有火焰伤害、毒药伤害、爆炸伤害等的伤害类型。
//An instigator is the controller responsible for the damage.
//This is the actual actor causing the damage.这是子弹本身
void DamageTaken(AActor *DamagedActor, float Damage, const UDamageType *DamageType, class AController *Instigator, AActor *DamageCauser)
GetOwner()->OnTakeAnyDamage.AddDynamic(this,&UHealthComponent::DamageTaken);
这样当我们产生damage事件时,我们都会从该委托中获得广播,这将导致调用damage taken函数。我们接着要使用ApplyDamage,这需要一些输入:
我们先在Projectile里定义一个Damge数值,然后在BasePawn-fire中
auto Projectile = GetWorld()->SpawnActor<AProjectile>(ProjectileClass,Location,Rotation);
Projectile->SetOwner(this);
这样我们就可以访问最新生成的子弹;然后当pawn生成子弹时,它会设置那个子弹的owner,这样我们再使用GetOwner时,我们会获得那个拥有子弹的class的实例。
我们在projectile-Onhit中:
auto MyOwner = GetOwner();
if(MyOwner == nullptr) return;
auto MyOwnerInstigator = MyOwner->GetInstigatorController();
auto DamageTypeClass = UDamageType::StaticClass();
if (OtherActor&& OtherActor != this && OtherActor != MyOwner){
UGameplayStatics::ApplyDamage(OtherActor, Damage, MyOwnerInstigator, this, DamageTypeClass);
Destory();
}
接着回到DamageTaken中:
if(Damage<=0.f) return;
Health -= Damage;
UE_LOG(LogTemp,Warning,TEXT("Health: %f"),Health);
4.3 Death
我们使用GameMode来确定游戏的开始和结束,我们首先创建一个GameMode的C++类:
然后再创建一个基于他的蓝图:
?然后再项目设置中,将其设置为默认游戏模式。并在蓝图中修改默认pawn类为坦克蓝图:
?接下来为了实现death,我们有如下思路:
- 创建HandleDestruction(BasePawn)
- 创建ActorDied函数(GameMode)
- Call?HandleDestruction in ActorDied
- Call?ActorDied 当?health成为0时
第一步:我们先定义HandleDestruction函数,然后进入Tower中。对于炮台,我们先进行简单的摧毁。
void ATower::HandleDestruction(){
Super::HandleDestruction();
Destroy();
}
对于坦克,我们现阶段希望其能隐藏。
void ATank::HandleDestruction(){
Super::HandleDestruction();
SetActorHiddenInGame(true);
//禁用Tick
SetActorTickEnabled(false);
}
第二步+第三步:
public:
void ActorDied(AActor* DeadActor);
void AToonTanksGameMode::ActorDied(AActor* DeadActor){
//如果坦克被摧毁了
if(DeadActor == Tank){
Tank->HandleDestruction();
if(Tank->GetTankPlayerController()){
//禁止输入按键响应
Tank->DisableInput(Tank->GetTankPlayerController());
//确保Mouse cursor不显示
Tank->GetTankPlayerController()->bShowMouseCursor = false;
}
}
else if(ATower* DestoryedTower = Cast<ATower>(DeadActor)){
DestoryedTower->HandleDestruction();
}
}
第四步,我们首先要在HealthComponent中获得GameMode:
class AtonTanksGameMode* ToonTanksGameMode;
在BeginPlay中:
ToonTanksGameMode = Cast<AToonTanksGameMode>(UGameplayStatics::GetGameMode(this));
然后在DamageTaken中:
if(Health <= 0.f &&ToonTanksGameMode){
ToonTanksGameMode->ActorDied(DamagedActor);
}
现在我们就可以在场景中消灭炮台,并被消灭。但是我们还需要添加音效,特效,胜利及失败界面等内容。
五、游戏特效及输赢界面
5.1?custom player Controller
我们首先创建一个PlayerController的c++类,和一个基于它的蓝图类。我们需要设置这个蓝图类为默认Player Controller。只要在GameMode蓝图中设置一下即可:
?然后添加代码:
void AToonTanksPlayerController::SetPlayerEnabledState(bool bPlayerEnabled){
if(bPlayerEnabled){
GetPawn()->EnableInput(this);
}
else{
GetPawn()->DisableInput(this);
}
bShowMouseCursor = bPlayerEnabled;
}
这样我们可以将之前ActorDied中的部分代码用这个函数替换掉。
我们再进入该蓝图中,设置默认鼠标光标为十字准星:?
当然现阶段我们还不能看到,因为我们还没有显示鼠标。
5.2?Starting the Game
我们回到GameMode中,在private:
//过多久游戏可以开始,并接受玩家的输入
float StartDelay = 3.f;
//函数
void HandleGameStart();
在cpp中:
void AToonTanksGameMode::HandleGameStart(){
//移动之前的BeginPlay代码
Tank = Cast<ATank>(UGameplayStatics::GetPlayerPawn(this, 0));
ToonTanksPlayerController =Cast<AToonTanksPlayerController>(UGameplayStatics::GetPlayerController(this,0));
if(ToonTanksPlayerController){
ToonTanksPlayerController->SetPlayerEnabledState(false);
FTimerHandle PlayerEnabledTimerHandle;
FTimerDelegate PlayerEnabledTimerDelegate = FTimerDelegate::CreateUObject(
ToonTanksPlayerController,
&AToonTanksPlayerController::SetPlayerEnabledState,
true
);
GetWorldTimerManager().SetTimer(
PlayerEnabledTimerHandle,
PlayerEnabledTimerDelegate,
StartDelay,
false
);
}
}
这样我们进入游戏后,3秒不能接受玩家的输入,且我们的鼠标变为了十字瞄准。
接下来我们要在屏幕上显示这些信息,这需要用到蓝图的implementable event。我们首先在GameMode的protected下:
//我们不需要在C++中为其提供body,虚幻会希望我们在蓝图中完成它的实现
UFUNCTION(BlueprintImplementableEvent)
void StartGame();
然后在HandleGameStart函数中加入StartGame,进入引擎编译,然后进入GameMode蓝图中。
我们可以在蓝图中加入StartGame事件:
?接着我们创建一个新的控件蓝图(这部分我们之前有提到过):
?并在里面加入一个简单的文本框:
?我们接下来要尝试将其加入到屏幕中,回到蓝图:
这只是个简单的实现,我们要做的是在屏幕上显示倒计时,这部分主要用蓝图实现,和之前的内容多有重复,就不多做记录,只放入最后的蓝图。
?5.3 获胜和失败界面
我们可以创建一个Gameover函数来完成这个目标,在GameMode中:
UFUNCTION(BlueprintImplementableEvent)
void GameOver(bool bWonGame);
并在ActorDied中加入函数,false:
GameOver(false);
在Towerdead中,加入计数,如果敌人被全部消灭,则为true。为了完成计数,我们创建:
int32 TargetTower = 0;
int32 GetTargetTowerCount();
我们可以使用GetAllActorsOfClass:
int32 AToonTanksGameMode::GetTargetTowerCount(){
TArray<AActor*> Toewers;
UGameplayStatics::GetAllActorsOfClass(this,ATower::StaticClass(),Toewers);
return Toewers.Num();
}
并在HandleGameStart中获取TargetTower,我们还要保证其更新:
--TargetTower;
if(TargetTower == 0){
GameOver(true);
}
这样我们可以在GameMode蓝图中创建GameOver事件,并将其添加到ViewPort:
?我们先将之前的StartGame复制一份,命名为EndGame,删除除了DisPlay文本之外的所有蓝图和变量,然后再GameMode中添加蓝图:
?这样我们的胜利和失败条件和界面就完成了。
5.4 特殊效果
我们需要回到之前的Projectile中添加
UPROPERTY(EditAnyWhere,Category = "Combat")
class UParticleSystem* HitParticles;
然后回到子弹蓝图中添加对应效果(可以自行选择免费素材)。然后我们需要在子弹击中后生成该粒子效果。我们在Projectile的OnHit中添加:
void AProjectile::OnHit(
UPrimitiveComponent* HitComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
FVector NormalImpulse,
const FHitResult& Hit
){
auto MyOwner = GetOwner();
if(MyOwner == nullptr) {
Destroy();
return;
}
auto MyOwnerInstigator = MyOwner->GetInstigatorController();
auto DamageTypeClass = UDamageType::StaticClass();
if (OtherActor&& OtherActor != this && OtherActor != MyOwner){
UGameplayStatics::ApplyDamage(OtherActor, Damage, MyOwnerInstigator, this, DamageTypeClass);
if(HitParticles){
UGameplayStatics::SpawnEmitterAtLocation(this,HitParticles,GetActorLocation(),GetActorRotation());
}
}
Destroy();
}
然后我们编译进入游戏,查看效果,这是子弹击中的效果:
接下来我们再添加跟随子弹的粒子系统,我们需要添加组件来完成这件事。我们在Projectile添加:
UPROPERTY(EditAnyWhere,Category = "Combat")
class UparticleSystemComponent* TrailPatticles;
TrailPatticles = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("Smoke Trail"));
TrailPatticles->SetupAttachment(RootComponent);
然后我们进入蓝图分配资源即可,查看效果:
?接下来在BasePawn中我们添加死亡的特效:
UPROPERTY(EditAnywhere,Category = "Combat")
class UParticleSystem* DeathParticles;
并在HandleDestruction中:
void ABasePawn::HandleDestruction(){
if(DeathParticles){
UGameplayStatics::SpawnEmitterAtLocation(this,DeathParticles,GetActorLocation(),GetActorRotation());
}
}
然后同样的,我们进入蓝图进行设置。
?接下来我们再为其添加声音,首先创建对应变量,然后进入蓝图进行设置: 在Projectile:
UPROPERTY(EditAnyWhere,Category = "Combat")
class USoundBase* LaunchSound;
UPROPERTY(EditAnyWhere,Category = "Combat")
USoundBase* HitSound;
BasePawn:
UPROPERTY(EditAnywhere,Category = "Combat")
class UParticleSystem* DeathParticles;
?设置完成后,我们就要播放这些音效。进入Projectile的OnHit中:
if(HitSound){
UGameplayStatics::PlaySoundAtLocation(this,HitSound,GetActorLocation());
}
BeginPlay:
if(LaunchSound){
UGameplayStatics::PlaySoundAtLocation(this,LaunchSound,GetActorLocation());
}
同样的对Basepawn。
六、结尾
我们对游戏进行最后的优化:
- 使摄像机移动更加平滑
- 解决玩家死亡后,敌人仍在射击的问题
首先我们进入坦克并选中SpringArm,勾选下面两项:
?同样我们也可以通过调整下面的参数来调整,使摄像机的移动更加平滑。
关于下一个问题,我们进入Tank,创建一个bool变量:
bool bAlive = true;
然后进入HandleDestruction中,将其设置为false。然后进入Tower中
void ATower::CheckFireCondition(){
if(Tank == nullptr){
return;
}
if (InFireRange() && Tank->bAlive)
{
Fire();
}
}
到此为止,这个小游戏就完成了。
|