之前在2D部分做过一个简单的敌人。这次在3D中又来做一个简单的敌人。
我们先来实现一些基本的功能,暂时不关心它的具体视觉表现。所以我简单地用一个胶囊表示就行了。当然你也可以导入一个3D模型什么的。
当然,如果你像我一样懒,只给了一个简单的 MeshInstance3D 占位的话,那么记得再给它个什么东西表示他的前方。不然我们不好调试。
根节点依然用方便的 CharacterBody3D ,但是和玩家不同,就目前来说不需要给它摄像机。添加脚本即可。
由于我们不需要控制敌人,所以脚本模板中只需要留下重力相关的代码即可。
这里说的行为其实算是敌人AI的一部分。要实现敌人的AI,其实没那么简单。我们也没法在一篇文章中说完(可能“永远”也说不完)。我们先做一个看向玩家的功能。例如我们想要实现,“玩家离敌人只有5m的时候,就看向玩家”。
如果 一定要 计算两点之间的距离,我们应该使用 Vector3 的 distance_to 或者 distance_squared_to 来求得(毫无疑问 Vector2 也有对应的方法)。这是两个实例方法。后者会比前者快一些,后者计算的是没有开平方的距离。
两点距离是如何求得的?不要忘了,我们把点也视作向量。“AB间的距离”就是两点之间拉一个向量的长度。这个向量就是两点坐标之差。由于向量是有方向的,所以A-B得到的其实是从B指向A的向量。这里可能会显得反直觉,是需要注意的地方。当然,要计算的“距离”是一个标量,所以这里其实也不那么重要。
要推导出这个距离怎么算其实视角有很多,但是这里我肯定说中小学生都能理解的。在2D空间中,想象一条斜着的向量。这个向量在X轴上的投影(v),v末端和P的连线,构成了一个三角形。这个三角形斜边的长度——勾股定理知道吧?那你就会求向量长度了。两条直角边的长度恰好就是向量的两个分量。公式我就懒得打了,简单用伪代码表示就是 sqrt(x*x + y*y) ,三维空间中也是一样,只不过是三个分量的平方和。
这就是求准确距离需要开方的原因,如果你不需要那么精确,那么就直接用没开方的结果即可。由于我们这里明确知道是“5m以内”,那么直接 position.distance_squared_to(player.position) < 25 即可。当然这里你需要用一种办法在场景中找到player,然后每帧做这个运算去检查。
然而,这里实际上不用真的去算这个距离。还记得 Area 吗?一个可以用来检查是否有可碰撞对象的没有实体的区域。在3D中我们同样有 Area3D 来做这个事情。为Enemy套上一个 Area3D ,给它一个shape然后调整到适当大小,连上 body_entered 即可检查玩家是否进入!具体操作不赘述。
注意,实际上这里需要调整碰撞层让它不检查enemy自己,因为 CharacterBody3D 本身也要参与碰撞的。这里请自行调整或者偷懒。
接下来要实现“看向玩家”。直接说结论,需要使用 Transform3D 的 looking_at 方法。名字已经很直白了,就是要让这个transform“看向”某个点。第二个参数指定“上方”,用这个方向确定这个transform会绕哪个方向转过去。默认就是Y轴正方向(通常意义的上方),所以调用时可以省略。
现在 Area3D 的 body_entered 信号的处理函数大致是这样:
func _on_area_3d_body_entered(body: Node3D) -> void:
if body is not FpsCharacter:
return
transform = transform.looking_at(body.position)
为什么需要给transform赋值?因为 looking_at 方法并不直接修改transform本身,它会返回一个新的 Transform3D 实例。
现在玩家进入Enemy的area时,敌人会一瞬间看向玩家:
有被吓到。如果这是你想要的效果,那也行。但是你可能期望可以看到敌人转身,哪怕转身很快也要能看到这个过程感觉才真实。当然,这里也不是bug,因为你代码就是这么写的。我们就是在某一帧里直接设置了transform,所以这个变换肯定也是一瞬间完成。
如何解决?说白了,要达到这种效果,我们需要让这transform的这种变化,不在一帧中完成。但是我们只有起始值和最终值,如何获得在中间这些帧的值呢?
话都说到这个份上了,肯定就是要插值。 Transform3D 是一个相对复杂的数据结构,所以一般情况下不可能我们自己去插值。它提供了一个 interpolate_with 方法来插值。第一个参数就是目标值,第二个参数为一个取值为0到1的值,就是说插值到哪里了。
var turning = false
var turn_alpha = 0
var turn_time = 1
var turned_time = 0
var target_transform: Transform3D
由于我们要在 Area 的信号响应函数中确定何时开始插值,因此目标transform要在这里确定,但是要在其它函数中使用。在处理 body_entered 的函数中,我们不直接设置transform,只是确定目标并指出正在转向:
func _on_area_3d_body_entered(body: Node3D) -> void:
# ...
if turning:
return
target_transform = transform.looking_at(body.position)
turning = true
在 _physics_process 中,我们只有在 turning 时才处理插值:
func _physics_process(delta: float) -> void:
# ...
if turning:
turned_time += delta
turn_alpha = clamp(turned_time/turn_time, 0, 1)
transform = transform.interpolate_with(target_transform, turn_alpha)
if transform.is_equal_approx(target_transform):
turning = false
turned_time = 0
turn_alpha = 0
move_and_slide()
实际上也不复杂。 turn_time 是一个我们自行设计的值,也就是整个转身过程需要多长时间。 turned_time 指的是已经转了多少时间了,每次进入 _physics_process 时就给它加上时间。 turn_alpha 就是两者之比,也就是插值进度,它会作为 interpolate_with 的参数传入。当然这个中间变量是 没有必要 的,这里为了清楚写一下(题外话,如果编译器足够智能,可能就算这么写这个变量也会被优化掉)。
如果插值后发现当前transform已经和目标差不多了,那么就重置这些状态。
为什么不用等号?因为由于浮点数精度等问题,很难确定两者精确相等。如果在这里用等号,你会发现几乎不可能碰到两者相等的时候!但是!这里实际上不用比较transform,我们直接比较时间即可!简化后的代码:
if turning:
turned_time += delta
transform = transform.interpolate_with(target_transform, clamp(turned_time/turn_time, 0, 1))
if turned_time >= turn_time:
turning = false
turned_time = 0
为了这个简单的插值操作我们定义了大量状态变量,如果我们需要控制很多插值,可能还要定义很多变量。
别忘了,我们还有Tween可以用。如果不需要那么精细地控制,我们可以直接用Tween来插值。用tween的话,我们就可以不用自己来计算时间和插值用的alpha(weight),很多代码又可以省掉:
func _on_area_3d_body_entered(body: Node3D) -> void:
# ...
if turning:
return
target_transform = transform.looking_at(body.position)
turning = true
var tween = create_tween()
tween.tween_property(self, "transform", target_transform, turn_time)
tween.finished.connect(func(): turning = false)
当然这里保留了turning用以判断是否还在转身过程中。这里相关的行为可以根据游戏设计调整。当然,手动挡已经告诉你怎么开了,当你真的需要手动挡的时候,你应该知道怎么做。
在2D部分的文章中,为了实现玩家的HP特性,我们直接在玩家的脚本中定义了一个属性。同时由于那时的敌人不需要被击杀,所以它也没有HP。很多FPS中,玩家和敌人都会被击杀,所以双方一般都是有HP属性的。当然,对于玩家和敌人来说,可能受到伤害的代码都是类似的。
我们已经实现了通过计算射线和物体是否相交来判断能否击中某物。如果可以击中,我们就可以获得一个代表被击中物的collider,其类型为 Node3D ,我们知道,这是非常抽象的类型,它可以代表任意3D节点。
为了对被击中物施加伤害,我们首先要知道它 是否可以接受伤害 。
我们如何知道一个节点是否可以受到伤害呢?根据我们对GDScript的了解,我们可以想到可以对它进行类型检查。假如我们在代表敌人的类上定义了一个hp属性,如果这个东西是敌人,那么肯定就可以操作它的hp,亦可以调用其它相关方法:
# 只作为例子展示
func fire():
# ...
var result = get_world_3d().direct_space_state.intersect_ray(parameters)
if not result:
return
var collider = result.collider
if collider is Enemy:
var e = collider as Enemy
e.apply_damage(1)
# ...
# ...
随着开发的进行,我们可能有多种不同的敌人,需要实现类似的处理伤害的功能,可以预见这些代码是雷同的。为了减少这种不必要的复制粘贴,我们可以为各种敌人定义一个共有的基类。这样在应用伤害时可以不用关心具体敌人。
这样的通用敌人基类的基类可能是 CharacterBody3D 。但是,有没有可能我们的敌人不需要、甚至不应该继承 CharacterBody3D 呢?比如它可能是一个载具或者其它什么东西,它们不利用 CharacterBody3D 实现。
还有一种可能,一个东西会对攻击做出反应,但是它并不会被打死,因此它不需要一个hp什么的,它怎么能被当成敌人的子类呢?我们甚至不能操作它的hp!
你可能会想,我们用最抽象的Node来表示一种“可以被打”的东西,以此为基类,再构造一些略为具体的基类,最后再是真正直接使用的具体节点类。
想法不错。但是如果我们需要抽象出不止一种行为呢?比如载具既可以被攻击,也可以被“开”。如果它继承了一个可以被攻击的基类,那么它就不能再去继承另一个被开的基类了。
GDScript以及很多基于类的面向对象编程语言实际上都主动抛弃了多继承(可以同时有多个基类)来避免多重继承带来的一些问题。所以上述设想在GDScript中是没法实现的。
那怎么办呢?既然我们知道可以把某种能力剥离出来,那么我们也就可以把它包装成一个单独的节点。Godot的节点树设计天然地让我们可以让一个节点作为根节点,让它带有若干子节点。这样一来这个根节点就可以利用这些子节点的能力来实现各种功能。并且,HP相关的数据、状态本身是不需要和视觉呈现交互的。把它(比如叫它HpComponent)作为一个Node的派生类来实现也是合情合理的:
# HpComponent示例
extends Node
class_name HpComponent
@export var MAX_HP: int = 100
var hp: int = MAX_HP
signal damaged()
signal died(node: Node3D)
func apply_damage(d: int) -> int:
hp = clamp(hp - d, 0, MAX_HP)
damaged.emit()
if hp == 0:
died.emit(get_parent())
return hp
当然,如此一来我们就无法直接从一个节点的类型上得到它关于这一能力的信息,因为现在和接受伤害相关的能力有关的属性和方法都在这个单独的子节点上。如果使用这种模式,为了检测一个节点“是否可以接受伤害”,我们需要做的是检查它身上是否有某个节点。
在Godot中可以使用 get_node 和 get_node_or_null 来获得对节点树中某个节点的引用。这两个方法唯一的区别就是前者在找不到指定节点时会发生错误并返回null,后者不会报错。这个节点本质上就等于用GDScript的语法糖来获得节点,就像我们在脚本中做过的那样。只不过,在我们需要检查来自另一个场景或节点身上有没有某个节点时,我们就要通过这个方法来操作:
func fire():
# ...
if not result.collider:
return
var collider = result.collider
var hp_component = collider.get_node_or_null("HpComponent")
if hp_component:
hp_component.apply_damage(1)
# ...
由于我们需要通过一个类似于字符串的参数来获得节点,所以说并不能保证这个过程一定成功。虽然说这种实现具体功能的节点我们可能一般就作为根节点的直接子节点(第一层),但是你必须要在开发过程中和自己以及所有开发参与者达成一致,不然的话可能你在某些能被伤害的节点上用HpComponent可以找到这样一个HpComponent节点,有些东西上又找不到了。另外,你还有可能给它改了名字或者打错字了什么的。所以很多时候用字符串当参数的约束力是很弱的,类型系统相比之下要安全很多。
当然这种模式的好处是显而易见的。我们可以在任意场景中加入这个HpComponent,不用管它们的继承关系,也不用关心具体类型,并且可以组合其它任意的功能。
当然,还有个问题没有回答,那就是万一这个东西根本不需要一个HP属性呢?
不要忘了,GDScript是一个足够动态的编程语言。我根本不需要关心你到底是哪种节点,甚至不需要关心你有没有某个子节点——只要你有叫某某的属性或者方法,我就可以调用之!
你可以在所有可以受伤害的节点上都定义一个 apply_damage 方法,并且使用统一的参数列表。这样一来,不管被射线碰到的节点是什么,也不管它如何处理伤害,都可以通过这个方法来处理。
但是,不要忘了,如果这个被射线碰到的东西没有这个方法,那么就会报错。如果要利用动态语言的这一特性来实现,还是那句话,事先约定,保持一致。
针对这一问题,GDScript中 可以 通过 Object 的 has_method 方法来检查一个节点(提醒:Node是Object的子类)上是否有某个方法。 但是 ,在游戏运行时调用这种方法比直接调用会慢得多。
利用这种动态语言的特性来实现还有一个坏处就是,就算你的代码是完全正确的,由于类型不确定,编辑器无法给你提供代码补全的提示,出现打字错误的可能性更高。
因此,为了方便起见,我文章中的示例代码就定义 apply_damage 方法,然后直接动态调用它就行了。
当然,如果我用C#来编写脚本,我不会这样做。但是如果你一定要问可不可以,那其实还是可以。但是那样就白瞎了C#带来的类型安全。
这种对具体能力的抽象,对实现和接口的剥离,在“正经的”编程语言中一般就称为interface(接口),或者用苹果更喜欢的说法protocol(协议)表示。
这种抽象的接口定义了一系列方法(某些语言也支持定义属性),要求接口实现者(某个类),和接口使用者(通过接口操作某个对象的代码)达成一种协议。以Godot和Unity等引擎支持的C#语言为例:
interface IDamageable
{
void ApplyDamage(int d);
}
这样一来, 承诺实现该接口的类就必须 提供一个相应的方法定义,且 任意类型 均可实现该接口。射线检测到实现了这个接口的节点时,就 必然 能调用这个接口中定义的方法。这种确定性是C#作为一种(在绝大多数情况下都很)静态的编程语言的类型系统能够保证的。
很多现代的基于类的面向对象编程语言都是只允许继承一个类,但是可以实现多个接口。例如等价的Enemy可以去实现这个接口:
public partial class Enemy: CharacterBody3D, IDamageable
{
# ...
[Export]
public int MaxHp {set;get;} = 100;
public int Hp {set; get;} = MaxHp;
public void ApplyDamage(int d)
{
Hp -= d;
}
}
void Fire()
{
// ...
var collider = result["collider"];
if (collider is IDamageable d)
{
d.ApplyDamage(1);
}
// ...
}
collider is IDamageable d 是C#模式匹配语法的一种用法。如果发现collider实现了 IDamageable 接口,那么就将其所指对象绑定到局部变量d上,便于在下方的代码块中使用。这里通过d只能调用接口中定义的方法。一般有接口概念的编程语言都可以在需要类型的地方写上接口,因此可以用接口类型的变量来引用对象。
如你所见,通过接口调用方法的代码是不用关心具体类型是如何实现这个方法的。这些具体的类型上你可以背上一个类似于HpComponent的节点来实现具体功能,也可以完全不用——我根本不关心好吧。
Unity虽然也用C#,但是它更强调组合。一个GameObject身上的各个组件构成了这个东西的能力。不过也可以通过一些手段让一个组件放到某个东西身上时自动给它加一个它依赖的组件,一定程度上可以避免找不到某个组件的问题。Unity的射线检测结果可以拿到的也是一个 GameObject (类似于Node2D和Node3D,因为GameObject有transform,但是Node没有),可以通过调用获得具体类型的组件。当然,还是因为有C#在,你可以定义一个类似的类层次结构,定义一个大的Enemy组件,让它实现接口,也可以用 GetComponent<IDamageable> 来获得具体的组件引用。
Unreal的编程语言是(被Epic加了很多黑魔法的)C++。C++其实是支持多重继承的,但是Unreal的开发者选择用黑魔法实现了一种类似于“现代面向对象语言”的接口。尽管Unreal不如Unity那么“强调”组合,但是Unreal中也存在组件的概念,再加上接口,因此实际上也可以用上述的各种方法来解决问题。
基本的逻辑对于从头看到这里的读者来说应该很简单,建议先自己思考一下。
我们这里的场景和敌人逻辑都很简单。事实上,这个功能涉及很复杂的情况。比如场景很复杂,敌人和玩家有高度差,简单的距离计算是找不到路的。后续会介绍一种“更先进”的做法。
这里的敌人是用 CharacterBody 实现的,玩家逼近后,我们直接修改速度。如果你的敌人响应玩家进入area的信号的函数里面,转向是用Tween来实现的,那么可以直接这样写:
# ...
tween.finished.connect(func():
turning = false
velocity = (body.position - position).normalized() * 0.5
# TODO: 这里应该加上追逐玩家的代码
)
正如前面提到的那样,两点坐标相减就是一个向量。这里用normalized方法得到规范化之后的向量,然后乘以我们设计的速度(speed)即可。
如果你的逻辑不复杂,这样直接写一个lambda表达式即可(快速回忆:形如 func(): pass 这样的、匿名的简单函数,这样的表达式直接返回一个函数或者说Callable)。还要注意就是这样的lambda表达式也可以有多行代码,不需要限制自己只能写一行代码。当然,当这里的代码真的复杂起来之后,正确的做法是提取一个单独的函数。
评论区
共 4 条评论热门最新