斯坦福UE4 + C++课程学习记录 18:十字准星
Posted Surkea
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了斯坦福UE4 + C++课程学习记录 18:十字准星相关的知识,希望对你有一定的参考价值。
目录
1. 创建准星UI
结合之前文章关于UMG的内容,我们可以十分快速地创建一个之子准星的UI,这一部分视频对应课程P20开始。
首先,我们需要调整一下摄像机的位置。如果我们现在运行关卡,会发现游戏角色位于镜头的正中间,这无疑会在操作中遮挡玩家的实现。回忆一下各种第三人称视角的游戏,人物通常位于画面的偏左或偏右的位置。
进入Player的蓝图编辑器,选择弹簧臂组件(SpringArmComp),调整其中的“摄像机”属性。通过设置“长度”可以变化摄像机与角色的距离,设置“插槽偏移”从而在改变相机位置时保持弹簧地碰撞检测的功能。这里大家可以根据自己的喜好自行调节视角,我在这个地方的设置如图18-1:
接下来,在UI文件夹下创建Crosshair_Widget控件蓝图,并添加一个图像控件。通常,十字准星固定在屏幕的正中央。因此,我们设置图像的锚点为屏幕中间点,设置位置X、Y为0,并把X与Y的对齐均设置为0.5。适当调节尺寸X、Y属性,使准星的大小合适。
回到Player蓝图中,在之前添加血量条的位置再把准星加上,同样添加到视口。然后运行关卡,就可以看到屏幕正中间有一个简单的准星了。
2. 调整发射代码
在此前第5节的文章中,我在SurCharacter中的PrimaryAttack使用了GetActorRotation函数来获取角色的旋转,从而使粒子以角色的正前方发射,如图18-5:
在添加十字准星后,我们肯定需要粒子沿着准星发射,即沿着玩家的视角发射。所以,我们只用把PrimaryAttack_TimeElapsed函数中的GetActorRotation()换成GetControlRotation()即可。此外,在当前项目下,我使用UE_LOG发现GetControlRotation()和GetViewRotation()的值相等,所以后面就统一用前者。
但这样的更改可能会带来两个问题:一是反方向(正脸面向摄像头)发射时可能会打中角色自己,这取决于魔法粒子蓝图的设置;二是击中的位置与准星还是存在偏差,尤其在角色朝向左边的时候(角色右手发射):
第一个问题很容易理解,反方向发射的魔法粒子检测到了自己角色的actor,就触发了OnActorOverlap事件。在此前第11节的内容中,我们已经使用了Instigator判断是不是玩家自己,所以第一个问题我并没有遇到。
但此处还要注意,粒子虽然可以穿过角色,但仍会对角色自己造成伤害。我们控制粒子销毁的功能是在蓝图中实现的,而控制伤害的功能是在代码中实现的(有点混乱,但这是课程出于教学目的的设计),所以我们还需要再C++中添加忽略对玩家造成伤害的代码。代码如下所示,其实就是在之前的基础上在第一个if处添加了对Instigator的判断,和蓝图中的逻辑是一样的。
void ASurMagicProjectile::OnActorOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
//避免攻击者被自己的粒子伤害
if (OtherActor && OtherActor != GetInstigator())
//获得AttributeComp
USurAttributeComponent* AttributeComp = Cast<USurAttributeComponent>(OtherActor->GetComponentByClass(USurAttributeComponent::StaticClass()));
// 再次判空,可能碰到的是墙壁、箱子等没有血量的物体
if (AttributeComp)
// 魔法粒子造成20血量伤害
AttributeComp->ApplyHealthChange(-20.0f);
// 一旦造成伤害就销毁,避免穿过角色继续计算
Destroy();
第二个问题就稍微复杂一些,我个人是这样理解的:粒子发射的方向是角色的相机方向,而粒子发射的位置是角色右手。也就是说,只要发射位置不在屏幕正中心(也就是相机的位置),最后粒子的落点一定存在偏移,且距离屏幕中间越远偏移越大。
一种最简单的解决办法是,直接设置魔法粒子的发射点在屏幕正中间,也就是相机位置。这个方法只修改需要一行代码,也就是把相机的Rotation和Location都赋给SpawnTM:
FTransform SpawnTM = FTransform(GetControlRotation(), CameraComp->GetComponentLocation());
这种方法的效果如图18-9,毫无疑问实现了指哪打哪。如果要使用这个方法,也许在每次攻击时把角色旋转到朝前,然后要精调角色在镜头中的位置,以及魔法粒子沿着GetControlRotation这个方向的具体生成位置,使其攻击时手部刚好和发射粒子的位置重合,感觉第一人称射击游戏可以更方便地使用这种方法(以上都是个人猜想)。
另一种很巧妙的方法,是先检测再发射。在发射魔法粒子前,先用射线检测的方法,检测一个从相机位置沿着其朝向的较大的射程范围内,有没有命中对象。如果检测到命中,返回命中的位置,并利用向量加法得到这次攻击的方向向量,然后再发射魔法粒子;如果不命中,最后就会落到沿着相机,距离为射程的那个点上。这种方法每次攻击的方向向量是不相同的,是判定完位置后再计算的。
实现方法和之前第7节打开箱子的射线检测很类似,这里使用形状检测来增加检测的空间,核心代码如下:
// 获取模型右手位置
FVector RightHandLoc = GetMesh()->GetSocketLocation("Muzzle_01");
// 检测距离为 5000 cm = 50 m
FVector TraceStart = CameraComp->GetComponentLocation();
FVector TraceEnd = TraceStart + ( GetControlRotation().Vector() * 5000 );
// 检测半径
FCollisionShape Shape;
Shape.SetSphere(20.0f);
// 不要检测自己角色
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
// 碰撞设置
FCollisionObjectQueryParams ObjParams;
ObjParams.AddObjectTypesToQuery(ECC_WorldStatic);
ObjParams.AddObjectTypesToQuery(ECC_WorldDynamic);
ObjParams.AddObjectTypesToQuery(ECC_Pawn);
FHitResult Hit;
if (GetWorld()->SweepSingleByObjectType(Hit, TraceStart, TraceEnd, FQuat::Identity, ObjParams, Shape, Params))
TraceEnd = Hit.ImpactPoint;
// 尾向量 - 头向量 = 方向向量 eg:起点(0,0) 终点(1,1),方向向量为(1,1)
FRotator ProjRotation = FRotationMatrix::MakeFromX(TraceEnd - RightHandLoc).Rotator();
// 朝向检测到的落点方向,在角色的右手位置生成
FTransform SpawnTM = FTransform(ProjRotation, RightHandLoc);
// 此处设置碰撞检测规则为:即使碰撞也总是生成
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnParams.Instigator = this;
GetWorld()->SpawnActor<AActor>(ProjectileClass, SpawnTM, SpawnParams);
原理已经十分清晰明了,但我使用如上代码在运行时并没有达到预期的效果。在我添加了DrawDebugLine和DrawDebugPoint进行调试后,终于发现问题的原因:预先检测时直接略过了预期对象,根据UE_LOG打印的内容显示,最后传回的Actor是地板Floor,如图18-10中黄球所示:
于是我顺着这个问题,对碰撞和物理属性的各种设置折腾了许久,最后终于发现把对象的碰撞预设设置为“BlockAll”后,代码就可以正常运行了。但这样的缺点在于,这些物体即使设置了“模拟物理”也不会和场景内的火药桶产生交互,火药桶爆炸后它们还死死地钉在原地。
接下来我只能从函数上入手,我看到UE对SweepSingleByObjectType的解释是发射一个形状,会返回第一个碰撞的对象。一看到碰撞这个字眼,我就突然意识到ObjParams里面传入了需要检测的对象,兴许是我场景中的对象不属于代码中的三种。我在UE中检查这面墙的对象类型,发现是PhysicsBody,于是我尝试性地在代码中加入了ECC_PhysicsBody。最后,终于在物体能模拟物理的基础上,被SweepSingleByObjectType检测到。最终效果如图18-11所示,其中黄色是形状检测的点,紫色是粒子触发命中事件的点。
看来根本的问题还是在UE的碰撞设置上,目前有关各种碰撞预设设置、命中事件、重叠事件等一些列问题理解还十分粗浅。所以暂且留个坑,待日后对UE有一定概念后再回头来系统研究这个问题。
UE4蓝图与C++交互——射击游戏中多武器系统的实现
回顾
??学习UE4已有近2周的时间,跟着数天学院“UE4游戏开发”课程的学习,已经完成了UE4蓝图方面比较基础性的学习。通过UE4蓝图的开发,我实现了类似CS的单人版射击游戏,效果如下视频:
??不得不说UE4蓝图功能的强大,无需写一句代码,就能实现一个基本的游戏玩法。并且使用门槛极低,只要熟悉蓝图的API,通过“拖拖,连连”就能完成游戏玩法的开发,对游戏策划(设计师)及其友好,与C++相比,生产效率极高。
多武器系统
??目前的游戏设定是开场后,角色身上就自动装备了一把武器,为了实现类似于CS雪地地图一样的设定:开场没有武器,地图中摆放着多把武器,需要从地上拾取后才能使用,就需要将当前的武器系统进行扩展,创建多个蓝图来实现不同武器的模型、特效、属性等等。
武器玩法逻辑的处理
??之前只有单个武器时,武器蓝图包含有玩法逻辑例如弹道计算、粒子显示、开枪动画、伤害控制等等,这些玩法(GamePlay)的逻辑是写在武器蓝图里面的。如果按照以前的设计,创建多个武器蓝图时,这些武器中的玩法逻辑也需要拷贝多份。
拷贝
??然而,“拷贝”这种开发方式在程序编码中是非常不推荐的。所谓拷贝,意味着需要维护多份同样逻辑实现的代码,如果后续需要对该部分玩法进行调整 优化(策划又要改需求),那么拷贝了多少次,就需要修改多少次。例如,项目后期有50把武器(参考穿越火线),如果在前期为了省事将武器的玩法逻辑拷贝了49次,那么如果某天有该逻辑的修改需求时,就需要将该修改操作重复50次,这是让人崩溃的一件事。因此,在软件开发中,不要轻易使用Ctrl-C + Ctrl-V。
引用
??那么该如何解决该问题呢?软件开发中常用的方法是将拷贝转变为引用。所谓引用,意思就是将公共的部分剥离出来,形成函数(方法),在需要该逻辑的地方引用该函数即可。如果后期有修改需求,只需要修改该函数一个地方,就可以实现多处逻辑被同时修改。同样,在UE4的蓝图中也提供了函数(Functions)这样的功能,通过创建函数可以将蓝图中公共的逻辑封装 ,从而实现多处引用。但是,UE4的蓝图是面向对象的,不同的武器(对象)之间是不能共用函数的,因此,将公共逻辑改为引用的方式是行不通的。
继承
??既然蓝图是面向对象的,那么可以使用面向对象的编程特点:继承。所谓继承,就是将函数、变量封装到父类,从该父类集成出来的子类可以使用父类暴露出来的方法与属性。利用继承的特性,于是我们就可以从蓝图的Actor类中继承出Weapon类,而我们游戏中的各种武器,例如手枪、冲锋枪、狙击枪、火箭炮等等可以使用武器中封装的一些逻辑,比如开枪,换弹匣,等等,继承图如下:
??
??可以看到,各种武器继承了Weapon之后,就拥有的子弹的变量,也拥有了开枪的函数,从而实现了武器逻辑的复用。
武器属性的设置
??不同的武器除了模型(Mesh)不同以外,还有子弹数量、开枪动画、装弹动画、子弹撞击粒子、子弹伤害等等不同的属性,不同的武器这些属性都不同,而这些属性都需要在父类Weapon中进行处理。那么如何才能为不同的武器配置这些属性呢?这就涉及到C++变量如何暴露给蓝图使用。
??根据官方文档:虚幻反射系统,C++中的变量可以被UPROPERTY()
宏修饰,就可以暴露给蓝图使用,还可以根据需要设定访问权限。
C++代码如下:
// 击中目标粒子
UPROPERTY(EditDefaultsOnly)
UParticleSystem* TargetFX;
// 开枪动画
UPROPERTY(EditDefaultsOnly)
UAnimationAsset* ShootAnim;
// 开枪间隔
UPROPERTY(EditDefaultsOnly)
float ShootInterval;
// 换弹匣动画
UPROPERTY(EditDefaultsOnly)
UAnimationAsset* ReloadAnim;
// 换弹匣时间
UPROPERTY(EditDefaultsOnly)
float ReloadTime;
// 每颗子弹伤害值
UPROPERTY(EditDefaultsOnly)
float Damage;
// 最大子弹数
UPROPERTY(EditDefaultsOnly)
int8 MaxBullet;
// 是否正在换弹匣
UPROPERTY(BlueprintReadOnly)
bool Reloading = false;
// 是否正在射击
UPROPERTY(BlueprintReadOnly)
bool Shooting = false;
// 当前子弹数(因为要暴露给蓝图获取,所以类型扩充到int32)
UPROPERTY(BlueprintReadOnly)
int32 CurrentBullet;
自动生成的蓝图设置如下:
??可以看到,如果变量是粒子指针和动画的指针,蓝图中则直接生成了对应的可视化选择框,太方便了有木有。这不就是策划所需要的配置表吗,还是可视化的,再也不用担心把文件名配错了。
武器逻辑
??除了变量,C++函数有类似的处理方法,宏UFUNCTION()
可以将函数暴露给蓝图,供蓝图调用。因此,上文中提到的换弹匣的逻辑,就可以移植到C++中,从而给予所有武器具有开枪与换弹匣能力。
开枪的核心代码如下(已精简):
// 获取坐标与朝向
World->GetFirstPlayerController()->GetPlayerViewPoint(Location, Rotation);
// 播放开枪动画
Mesh->PlayAnimation(ShootAnim, false);
// 计算终点坐标 = 起点坐标 + 方向 * 距离
FVector EndLocation = Location + Rotation.Vector() * 10000;
// 发射射线
World->LineTraceSingleByChannel(Result, Location, EndLocation, ECC_WorldStatic, ccq);
// 已击中
if (Result.Actor.IsValid())
// 播放粒子
FRotator EmitterRotation = FRotator(0, 0, 0);
AActor* Actor = Result.Actor.Get();
UGameplayStatics::SpawnEmitterAtLocation(Actor, TargetFX, Result.Location, EmitterRotation);
// 中弹的是Character
if (dynamic_cast<ACharacter*>(Actor) != NULL)
// 受伤害
ACharacter* Shooter = dynamic_cast<ACharacter*>(WeaponOwner);
UGameplayStatics::ApplyDamage(Actor, Damage, Shooter->GetController(), this, NULL);
接下来是换弹匣:
Reloading = true;
// 播放动画
Mesh->PlayAnimation(ReloadAnim, false);
// 设置延时回调
GetWorldTimerManager().SetTimer(ReloadTimer, this, &AWeapon::ReloadFinish, ReloadTime);
void AWeapon::ReloadFinish()
CurrentBullet = MaxBullet;
Reloading = false;
因为换弹匣并不是瞬间换好(按了R键需要等一定时间后子弹才会恢复),因此使用了定时器来实现。
遇到的问题
- 蓝图中绑定的Mesh无法传递到C++
??我按照之前的方法,在C++中定义好骨骼Mesh指针:
// 武器mesh
UPROPERTY(EditDefaultsOnly)
USkeletalMeshComponent* MeshComponent;
??编译之后,兴冲冲的跑到蓝图中准备选择Mesh,然而却发现蓝图中却没有出现MeshComponent这个字段,以为是C++代码没有编译到,于是就反复试了几次,结果还是没有。怎么办呢?怀疑是UE4的这个反射系统不支持USkeletalMeshComponent*
这种变量类型,把变量类型改为int32
后,果然,这个字段出现了。
??SkeletalMeshComponent不行,那么父类MeshComponent呢?
// 武器mesh
UPROPERTY(EditDefaultsOnly)
UMeshComponent* MeshComponent;
??然而,编译之后蓝图中还是没有......看来的确是不支持USkeletalMeshComponent*
或者UMeshComponent*
这种类型。怎么办呢?我突然想到,既然不支持在蓝图中直接选择默认值,那么在蓝图中调用set方法来设置该变量吧!
??于是,我将C++代码修改为:
// 武器mesh
UPROPERTY(BlueprintReadWrite)
USkeletalMeshComponent* MeshComponent;
??蓝图如下:
??这样虽然实现了需求,但是需要在每一把武器蓝图中做一样的调用,如果有50把武器呢?100把武器呢?这样做明显与我的期望不一致,因此这种方法虽然可行,但是不可取。
??还有其他办法吗?通过查询UE4 C++的API,我找到了AActor::GetComponentByClass
这个函数,官方文档的描述是:
Searches components array and returns first encountered component of the specified class
??即查找并获取本Actor的指定类型的组件的第一个。这不正是我需要的吗?如果我指定查找类型为USkeletalMeshComponent,而每个武器只有一个USkeletalMeshComponent,那这样不就从蓝图中获取到了绑定的Mesh吗?实现代码如下:
void AWeapon::BeginPlay()
Super::BeginPlay();
// 获得Mesh
MeshComponent = dynamic_cast<USkeletalMeshComponent*>(GetComponentByClass(USkeletalMeshComponent::StaticClass()));
??通过这种方式,实现了武器的全部逻辑从蓝图中去除。当新加一件武器时,只需要在武器蓝图属性中配置动画、粒子、子弹容量等属性后,就可以直接在游戏中使用。
蓝图与C++选择的思考
??既然UE4同时支持蓝图与C++,那么我们在开发时应该如何选择呢?官方文档有如下的解释:
程序员利用C++即可添加基础Gameplay系统,然后设计师可基于这些系统进行构建或利用这些系统为某个特定关卡或游戏本身创建自定义Gameplay。
??也就是说,程序员用C++开发一些基础的系统,例如本文当中的武器系统(Weapon),设计师(策划)即可利用该武器系统在蓝图上进行武器的扩充,设计出不同的武器;设计师(策划)也可以利用C++开发的基础系统,将这些系统在蓝图上进行组装,以构建更丰富的玩法系统。除此之外,
- 蓝图的可重构性非常非常非常低,如果某个模块的逻辑比较复杂,就会出现各种线条乱飞,非常的凌乱,过段时间再过来看,可能作者自己都看不明白了。因此我建议,对于比较复杂的逻辑,最好使用C++来实现,如果一定要用蓝图,请将该部分逻辑拆分为几段小逻辑来实现(充分利用蓝图的函数与宏)。
- 蓝图本身是作为二进制文件来保存(.uasset),在版本管理工具中(Git)无法进行差异性对比,如果对于某段频繁修改(升级)的逻辑,又想看到每次修改的变化,最好也使用C++来实现,可以对比每个版本的修改内容。
- 对于复杂的数学运算或循环次数较多的逻辑,也最好采用C++来实现,以保证运行效率。
以上是关于斯坦福UE4 + C++课程学习记录 18:十字准星的主要内容,如果未能解决你的问题,请参考以下文章
UE4 C++ UE4 C++ 入门(无参考项目)——技术宅阿棍儿 学习笔记 更新中~
UE4 Material 101学习笔记——13-18 完整的雨水着色器