第2部分
部分概述
在这部分中,我们将为游戏角色提供武器.
到这部分结束时,您将拥有一个可以使用小刀发射手枪,步枪和攻击的游戏角色. 游戏角色现在还将拥有过渡动画,并且武器将与环境中的对象进行交互.
注解
在继续本教程的这一部分之前,我们假设您已经完成了 第1部分. 完成的项目来自 :ref:`doc_fps_tutorial_part_one`将成为第2部分的起始项目
让我们开始吧!
制作系统来处理动画
首先,我们需要一种方法来处理不断变化的动画. 打开 Player.tscn
并选择 AnimationPlayer Node(Player
-> Rotation_Helper
-> Model
-> Animation_Player
).
创建一个名为 AnimationPlayer_Manager.gd
的新脚本,并将其附加到 AnimationPlayer.
将以下代码添加到``AnimationPlayer_Manager.gd``:
extends AnimationPlayer
# Structure -> Animation name :[Connecting Animation states]
var states = {
"Idle_unarmed":["Knife_equip", "Pistol_equip", "Rifle_equip", "Idle_unarmed"],
"Pistol_equip":["Pistol_idle"],
"Pistol_fire":["Pistol_idle"],
"Pistol_idle":["Pistol_fire", "Pistol_reload", "Pistol_unequip", "Pistol_idle"],
"Pistol_reload":["Pistol_idle"],
"Pistol_unequip":["Idle_unarmed"],
"Rifle_equip":["Rifle_idle"],
"Rifle_fire":["Rifle_idle"],
"Rifle_idle":["Rifle_fire", "Rifle_reload", "Rifle_unequip", "Rifle_idle"],
"Rifle_reload":["Rifle_idle"],
"Rifle_unequip":["Idle_unarmed"],
"Knife_equip":["Knife_idle"],
"Knife_fire":["Knife_idle"],
"Knife_idle":["Knife_fire", "Knife_unequip", "Knife_idle"],
"Knife_unequip":["Idle_unarmed"],
}
var animation_speeds = {
"Idle_unarmed":1,
"Pistol_equip":1.4,
"Pistol_fire":1.8,
"Pistol_idle":1,
"Pistol_reload":1,
"Pistol_unequip":1.4,
"Rifle_equip":2,
"Rifle_fire":6,
"Rifle_idle":1,
"Rifle_reload":1.45,
"Rifle_unequip":2,
"Knife_equip":1,
"Knife_fire":1.35,
"Knife_idle":1,
"Knife_unequip":1,
}
var current_state = null
var callback_function = null
func _ready():
set_animation("Idle_unarmed")
connect("animation_finished", self, "animation_ended")
func set_animation(animation_name):
if animation_name == current_state:
print ("AnimationPlayer_Manager.gd -- WARNING: animation is already ", animation_name)
return true
if has_animation(animation_name):
if current_state != null:
var possible_animations = states[current_state]
if animation_name in possible_animations:
current_state = animation_name
play(animation_name, -1, animation_speeds[animation_name])
return true
else:
print ("AnimationPlayer_Manager.gd -- WARNING: Cannot change to ", animation_name, " from ", current_state)
return false
else:
current_state = animation_name
play(animation_name, -1, animation_speeds[animation_name])
return true
return false
func animation_ended(anim_name):
# UNARMED transitions
if current_state == "Idle_unarmed":
pass
# KNIFE transitions
elif current_state == "Knife_equip":
set_animation("Knife_idle")
elif current_state == "Knife_idle":
pass
elif current_state == "Knife_fire":
set_animation("Knife_idle")
elif current_state == "Knife_unequip":
set_animation("Idle_unarmed")
# PISTOL transitions
elif current_state == "Pistol_equip":
set_animation("Pistol_idle")
elif current_state == "Pistol_idle":
pass
elif current_state == "Pistol_fire":
set_animation("Pistol_idle")
elif current_state == "Pistol_unequip":
set_animation("Idle_unarmed")
elif current_state == "Pistol_reload":
set_animation("Pistol_idle")
# RIFLE transitions
elif current_state == "Rifle_equip":
set_animation("Rifle_idle")
elif current_state == "Rifle_idle":
pass
elif current_state == "Rifle_fire":
set_animation("Rifle_idle")
elif current_state == "Rifle_unequip":
set_animation("Idle_unarmed")
elif current_state == "Rifle_reload":
set_animation("Rifle_idle")
func animation_callback():
if callback_function == null:
print ("AnimationPlayer_Manager.gd -- WARNING: No callback function for the animation to call!")
else:
callback_function.call_func()
让我们来看看这个脚本正在做什么:
让我们从这个脚本的类变量开始:
states
:用于保存动画状态的字典. (以下进一步说明)animation_speeds
:一个字典,用来保存我们想要播放的动画的所有速度.current_state
:一个变量,用于保存我们当前所处的动画状态的名称.callback_function
:用于保存回调函数的变量. (以下进一步说明)
如果您熟悉状态机,那么您可能已经注意到 states
的结构类似于基本状态机. 这里大致是如何设置``states``:
states
是一个字典,键是当前状态的名称,值是一个数组,里面有可以转换的所有动画及状态.例如,如果当前处于 Idle_unarmed
状态,只能过渡到 Knife_equip
、 Pistol_equip
、 Rifle_equip
和 Idle_unarmed
.
如果我们尝试转换到未包含在我们所处状态的可能转换状态中的状态,那么我们会收到警告消息并且动画不会更改. 我们也可以自动从一些状态转换到其他状态,这将在下面的”animation_ended”中进一步解释.
注解
为了保持本教程的简单性,我们没有使用 完整的 ‘状态机’ .如果你有兴趣了解更多关于状态机的知识,请看以下文章:
(Python示例)https://dev.to/karn/building-a-simple-state-machine-in-python
(C#示例)https://www.codeproject.com/Articles/489136/UnderstandingplusandplusImplementingplusStateplusP
``animation_speeds``是每个动画播放的速度. 有些动画有点慢,为了让一切看起来都很流畅,我们需要以更快的速度播放它们.
小技巧
请注意,所有触发动画都比正常速度快. 请记住以后再说!
``current_state``将保存我们当前所处的动画状态的名称.
最后, callback_function
将是一个 FuncRef 由游戏角色传入,用于在适当的动画帧中生成子弹. 答 FuncRef 允许我们传递一个函数作为参数,有效地允许我们从另一个脚本调用一个函数,这是我们以后使用它的方式.
现在让我们来看看 _ready
.
首先,我们使用 set_animation
函数将动画设置为 Idle_unarmed
,所以我们肯定会从那个动画开始.
接下来,我们将 animation_finished
信号连接到此脚本并将其指定为调用 animation_ended
. 这意味着每当动画完成时,都会调用 animation_ended
.
接下来我们看一下 set_animation
.
set_animation``将动画更改为名为``animation_name
的动画*如果*我们可以转换到它. 换句话说,如果我们当前所处的动画状态在”states”中有传递的动画状态名称,那么我们将更改为该动画.
首先,我们检查传入的动画名称是否与当前播放的动画名称相同.如果它们是相同的,那么就向控制台书写一个警告,并返回 true
.
其次,使用 has_animation
查看 AnimationPlayer 是否有名称为 animation_name
的动画.如果没有,返回``false``.
第三,检查 current_state
是否被设置.如果在 current_state
中有一个状态,那么就可以转换到所有可能的状态.
如果动画名称在可能的转换列表中,我们将 current_state
设置为传入的动画(animation_name
),告诉 AnimationPlayer 以混合时间播放动画 在 animation_speeds
中设置的速度为 -1
并返回 true
.
注解
混合时间是将两个动画混合/混合多长时间.
通过输入值”-1”,新动画立即播放,覆盖已播放的任何动画.
如果您输入一个”1”的值,一秒钟后新动画将以增加的力量播放,将两个动画混合在一起一秒钟,然后再播放新动画. 这导致动画之间的平滑过渡,当您从步行动画更改为正在运行的动画时,这看起来很棒.
我们将混合时间设置为”-1”,因为我们想立即更改动画.
现在来看看 animation_ended
.
``animation_ended``是一个函数,它将被调用 AnimationPlayer 当它完成播放动画时.
对于某些动画状态,可能需要在它完成后过渡到另一个状态.为了处理这个问题,我们会检查每一个可能的动画状态,如果需要,将过渡到另一个状态.
警告
如果您使用自己的动画模型,请确保没有动画设置为循环. 循环动画在到达动画结束时不会发送 animation_finished
信号,并且即将再循环.
注解
animation_ended
中的转换最好是 states
中数据的一部分,但为了使教程更容易理解,我们将对 animation_ended
中的每个状态转换进行硬编码.
最后是 animation_callback
,这个函数将被我们动画中的调用方法跟踪其调用.如果有一个 FuncRef 分配给 callback_function
,那么就会调用这个传递进来的函数.如果没有分配给 callback_function
的 FuncRef,就会向控制台打印出一个警告.
小技巧
尝试运行 Testing_Area.tscn
以确保没有运行时问题. 如果游戏运行但似乎没有任何改变,那么一切都正常.
准备好动画
现在我们已经有了一个运行的动画管理器,需要从玩家脚本中来调用它.不过在此之前,需要在发射动画中设置一些动画回调跟踪.
打开 Player.tscn
如果您没有打开并导航到 AnimationPlayer node(Player
->` Rotation_Helper` ->` Model ` ->` Animation_Player`).
我们需要给三个动画附加一个调用方法跟踪.手枪、步枪和刀的射击动画,让我们从手枪开始.点击动画下拉列表,选择 “Pistol_fire” .
现在向下滚动到动画轨道列表的底部. 列表中的最后一项应为``Armature / Skeleton:Left_UpperPointer``. 现在位于列表底部,单击动画窗口底部栏上的加号图标,紧邻循环按钮和向上箭头
这会弹出一个窗口,里面有几个选择.我们要添加一个调用方法跟踪,所以点击 “调用方法跟踪” 的选项.这将打开一个窗口,显示整个节点树.导航到 AnimationPlayer 节点,选择它,然后按确定.
现在位于动画轨道列表的底部,您将看到一个绿色轨道,其中显示”AnimationPlayer”. 现在我们需要添加我们想要调用回调函数的点. 擦洗时间线,直到到达枪口开始闪烁的点.
注解
时间轴是存储动画中所有点的窗口,每一个小点都代表一个动画数据点.
如要预览”Pistol_fire”动画,请选择Rotation Helper下面的 :ref:` 摄像机<class_Camera>` 节点,并检查左上角透视图下面的”预览(Preview)”框.
擦洗时间轴意味着让我们自己完成动画. 因此,当我们说”擦洗时间线直到达到某个点”时,我们的意思是在动画窗口中移动,直到到达时间轴上的点.
而且,枪口是枪弹出来的终点. 枪口闪光是当子弹射击时逃离枪口的闪光. 枪口有时也被称为枪管.
小技巧
在清量时间轴时,为了更精细地控制,按 Ctrl 键,用鼠标滚轮向前滚动可放大,向后滚动则会缩小.
您还可以通过将 Step(s)
中的值更改为更低/更高的值来更改时间线清理捕捉的方式.
到达所需位置后,右击 “Animation Player” 这一行,按 Insert Key
.在空的名称栏中,输入 animation_callback
,然后按 Enter .
现在,当我们在播放这个动画时,调用方法跟踪将在动画的那个特定点被触发.
让我们重复步枪和刀射击动画的过程!
注解
因为这个过程与手枪完全相同,所以这个过程的解释会稍微深入一点. 如果您迷路,请按照上面的步骤! 它完全相同,只是在不同的动画上.
从动画下拉列表中进入 “Rifle_fire” 动画.到动画轨道列表的最下方,点击列表上方的 “Add Track” 按钮,添加调用方法跟踪.找到枪口开始闪光的点,右键按 Insert Key
在轨道上的该位置添加调用方法跟踪点.
在打开的弹出窗口的名称栏中输入 “animation_callback” ,然后按 Enter .
现在我们需要将回调方法跟踪应用到刀动画中.选择 “Knife_fire” 动画,滚动到动画轨道的底部.点击列表上方的 “Add Track” 按钮,添加一个方法跟踪.接下来在动画的前三分之一处找一个点,将动画回调方法点放在这里.
注解
我们实际上不会开枪,动画是一个刺伤动画而不是射击动画. 在本教程中,我们重复使用枪械射击逻辑,因此动画的命名风格与其他动画一致.
从那里右击时间轴,点击 “Insert Key” .在名称栏中输入 “animation_callback” ,然后按 Enter .
小技巧
一定要保存您的工作!
完成后,我们就可以开始在玩家脚本中添加开火的功能了.我们需要设置最后一个场景,子弹对象的场景.
创建子弹场景
有几种方法可以处理电子游戏中枪支的子弹. 在本系列教程中,我们将探讨两种更常见的方法:对象和光线投射.
两种方法之一是使用子弹对象. 这将是一个穿越世界并处理自己的碰撞代码的对象. 在这种方法中,我们在枪的方向上创建/生成一个子弹对象,然后向前行进.
这种方法有几个优点.首先是我们不必将子弹存储在玩家中.我们可以简单地创建子弹,然后继续前进,而子弹本身将处理检查碰撞,发送适当的信号给它碰撞的对象,并销毁自己.
另一个优点是我们可以有更复杂的子弹运动.如果我们想让子弹随着时间的推移而微微下降,可以让子弹控制脚本慢慢地把子弹推向地面.使用物体也会让子弹需要时间来到达目标,它不会立即击中任何它所指向的东西.这样感觉更真实,因为现实生活中没有任何东西会瞬间从一个点移动到另一个点.
性能的一个巨大缺点. 虽然让每个子弹计算他们自己的路径并处理他们自己的碰撞可以提供很大的灵活性,但这需要以性能为代价. 通过这种方法,我们每一步计算每个子弹的运动,虽然这可能不是几十个子弹的问题,但当您可能有几百个子弹时,它可能会成为一个巨大的问题.
尽管性能受到了影响,但许多第一人称射击游戏包括某种形式的物体子弹. 火箭发射器是一个很好的示例,因为在许多第一人称射击游戏中,火箭不会立即在目标位置爆炸. 您也可以用手榴弹多次发现子弹作为物体,因为它们通常会在爆炸前在世界各地反弹.
注解
虽然我不能肯定地说是这种情况,但这些游戏 可能 以某种形式使用子弹物体:(这些完全来自我的观察. 它们可能完全错误 .我从未参与** 任何**以下游戏)
光环(火箭发射器、破片手雷、狙击步枪、暴力射击等)
命运(火箭发射器,手榴弹,聚变步枪,狙击步枪,超级动作等)
使命召唤(火箭发射器,手榴弹,弹道刀,弩等)
战场(火箭发射器,手榴弹,claymores,迫击炮等)
子弹对象的另一个缺点是网络. Bullet对象必须(至少)与连接到服务器的所有客户端同步位置.
虽然我们没有实现任何形式的网络(因为它将在其自己的整个教程系列中),但在创建第一人称射击游戏时要牢记这一点,特别是如果您计划在未来添加某种形式的网络.
我们将讨论的另一种处理子弹碰撞的方法是光线投射(raycasting).
这种方法在具有快速移动的子弹的枪支中非常常见,这些子弹很少随时间改变轨道.
我们不是创建一个子弹对象并通过空间发送它,而是从枪的枪管/枪口向前发送一条射线. 我们将光线投射的原点设置为子弹的起始位置,并根据长度调整子弹在空间中``行进``的距离.
注解
虽然我不能肯定地说是这种情况,但这些游戏 可能 会以某种形式使用光线投射:(这些完全来自我的观察. 它们可能完全错误 .我从来没有工作**任何 **以下游戏)
Halo(突击步枪,DMR,战斗步枪,契约卡宾枪,斯巴达激光等)
命运(自动步枪,脉冲步枪,侦察步枪,手枪,机关枪等)
使命召唤(突击步枪,轻型机枪,子机枪,手枪等)
战场(突击步枪,SMG,卡宾枪,手枪等)
这种方法的一个巨大优势是它的性能要求不高. 在空间中发送几百条光线对于计算机来说比发送几百个子弹对象 简单得多 .
另一个优点是我们可以立即知道我们是否在确实遇到了什么,或者当我们要求它时. 对于网络而言,这很重要,因为我们不需要通过互联网同步子弹移动,我们只需要发送光线投射.
然而,Raycasting确实有一些缺点. 一个主要的缺点是我们不能简单地以线性线以外的方式投射一条光线. 这意味着无论光线长度有多长,都只能用直线射击. 尽管可以通过在不同位置投射多条光线来创建子弹运动的幻觉,但这不仅难以在代码中实现,而且性能负担也更重.
另一个缺点是我们看不到子弹. 对于子弹物体,我们实际上可以看到子弹穿过空间,如果我们将一个网格附加到它上面,但由于光线投射立即发生,我们没有一个体面的方式来显示子弹. 您可以从光线投射的原点到光线投射相撞的点绘制一条线,这是显示光线投影的一种流行方式. 另一种方法是根本不绘制光线投射,因为从理论上讲,子弹移动得如此之快,我们的眼睛无论如何都看不到它.
让我们设置子弹对象——在调用”Pistol_fire”动画回调函数时,我们的手枪会创建的内容.
打开 Bullet_Scene.tscn
. 这个场景包含一个名为bullet的 Spatial 节点,附有一个 MeshInstance 以及一个带有 CollisionShape.
创建一个名为 Bullet_script.gd
的新脚本并将其附加到 Bullet
:ref:`Spatial <class_Spatial>`上.
我们将在根部移动整个子弹对象( Bullet
).使用 Area 来检查是否碰撞了什么东西
注解
为什么我们使用 Area 而不是 RigidBody? 不使用 RigidBody 节点发生交互.我们通过使用 Area 确保其他任何 RigidBody 节点,包括其他子弹,都不会受到影响.
另一个原因很简单,因为用以下方法检测碰撞更容易 Area!
这是控制我们子弹的脚本:
extends Spatial
var BULLET_SPEED = 70
var BULLET_DAMAGE = 15
const KILL_TIMER = 4
var timer = 0
var hit_something = false
func _ready():
$Area.connect("body_entered", self, "collided")
func _physics_process(delta):
var forward_dir = global_transform.basis.z.normalized()
global_translate(forward_dir * BULLET_SPEED * delta)
timer += delta
if timer >= KILL_TIMER:
queue_free()
func collided(body):
if hit_something == false:
if body.has_method("bullet_hit"):
body.bullet_hit(BULLET_DAMAGE, global_transform)
hit_something = true
queue_free()
让我们过一遍脚本:
首先我们定义一些类变量:
BULLET_SPEED
:子弹前进的速度.BULLET_DAMAGE
:子弹对碰撞物造成的伤害.KILL_TIMER
:子弹未击中情况下的的存在时间.timer
:一个用于跟踪子弹存在时间的浮点数.hit_something
:一个布尔值,用于跟踪我们是否击中了某些东西.
除了 timer
和 hit_something
之外,所有这些变量都会改变子弹与世界的交互方式.
注解
我们使用kill timer 是因为子弹不会无休止地运动下去. 通过使用kill timer,我们可以确保不会出现子弹一直移动而消耗资源的问题.
小技巧
与 doc_fps_tutorial_part_one`一样,我们定义了几个全大写的类变量. 这背后的原因与 :ref:`doc_fps_tutorial_part_one:给出的原因相同:我们希望将这些变量作为常量使用,但同时也希望能够更改它们. 在本例中,我们稍后需要更改这些子弹的伤害和速度,因此我们需要让它们成为变量而不是常量.
在 _ready
中,我们将区域的 body_entered
信号设置为我们自己,以便在物体进入该区域时调用 collided
函数.
_physics_process
获得子弹的局部``Z``轴. 如果你以局部模式观察场景,你会发现子弹面向局部 Z
正轴.
接下来,我们将整个子弹按前进方向进行平移,乘上速度和时间增量.
之后我们将时间增量加到计时器上,检查计时器是否大于或等于我们的 KILL_TIME
常量. 如果是,就调用 queue_free
释放子弹.
在 collided
中,我们检查子弹是否击中东西.
请记住,只有当一个实体进入 Area 节点时才会调用 collided
. 如果子弹尚未与某些东西发生碰撞,我们将继续检查子弹发生碰撞的物体是否具有名为”bullet_hit”的功能/方法. 如果有,我们调用并传递子弹的伤害和子弹的全局变换,这样我们就可以获得子弹的旋转和位置.
注解
在 collided
中,传入的物体可以是 StaticBody, RigidBody,或者 KinematicBody
我们将子弹的 hit_something
变量设置为 true
,因为无论子弹碰撞的物体是否具有 bullet_hit
函数/方法,它都已经击中了一件东西,所以我们需要确保子弹不再击中任何其他东西.
然后我们使用 queue_free
释放子弹.
小技巧
你可能想知道,既然我们已经使用了 queue_free
在子弹击中某物的时候释放子弹,为什么还需要一个 hit_something
变量.
我们需要跟踪是否击中某物的原因是 queue_free
没有立即释放节点,所以子弹可能会在Godot有机会释放它之前与另一个物体发生碰撞. 通过追踪子弹是否击中,我们可以确保子弹只击中一个物体.
在我们再次开始编程游戏角色之前,让我们快速看一下 Player.tscn
. 再次打开 Player.tscn
.
展开 Rotation_Helper
并注意它有两个节点: Gun_Fire_Points
和 Gun_Aim_Point
.
``Gun_aim_point``是子弹瞄准的点. 注意它是如何与屏幕中心对齐并在Z轴上向前拉一段距离. ``Gun_aim_point``将作为子弹在进行时肯定会碰撞的点.
注解
这里有一个用于调试的隐形网格实例.这个网格是一个小球体,可以直观地显示子弹将瞄准的位置.
打开 Gun_Fire_Points
,你会发现另外三个 Spatial 节点,每把武器各一个.
打开 Rifle_Point
,你会找到一个 Raycast 节点. 我们将在这里为我们的步枪子弹发送光线投射. 光线投射的长度将决定我们的子弹能走多远.
我们使用 Raycast 节点来处理步枪的子弹,因为我们想要快速发射大量子弹. 如果我们使用bullet对象,很可能会在旧机器上遇到性能问题.
注解
如果你想知道发射点的位置,它们大致就在每个武器的末端. 你可以转到 AnimationPlayer
,选择其中一个发射动画并拖动时间轴来观察. 每种武器的发射点应该基本与它的末端对齐.
打开 Knife_Point
,你会看到一个 Area 节点. 我们对刀应用 :ref:`Area <class_Area>`是因为我们只关心接近我们的所有物体,并且刀不会被射向空中. 而如果我们要制作一把可投掷的飞刀,我们更可能会生成一个看起来像刀子的子弹物体.
最后,我们有了 Pistol_Point
. 这是我们将创建/实例化子弹对象的点. 我们这里不需要任何额外的节点,因为子弹处理它自己的所有碰撞检测.
现在我们已经看到了我们将如何处理我们的其他武器,以及我们将在哪里产生子弹,让我们开始努力让它们发挥作用.
注解
如果需要,您还可以查看HUD节点. 除了使用单个参考之外没有任何花哨的东西 Label,我们不会触及任何这些节点. 检查 :ref:`doc_design_interfaces_with_the_control_nodes`获取有关使用GUI节点的教程.
创造第一个武器
让我们为每个武器编写代码,从手枪开始.
选择 Pistol_Point
( Player
-> Rotation_Helper
-> Gun_Fire_Points
-> Pistol_Point
)并创建一个名为 Weapon_Pistol.gd
的新脚本.
将以下代码添加到``Weapon_Pistol.gd``:
extends Spatial
const DAMAGE = 15
const IDLE_ANIM_NAME = "Pistol_idle"
const FIRE_ANIM_NAME = "Pistol_fire"
var is_weapon_enabled = false
var bullet_scene = preload("Bullet_Scene.tscn")
var player_node = null
func _ready():
pass
func fire_weapon():
var clone = bullet_scene.instance()
var scene_root = get_tree().root.get_children()[0]
scene_root.add_child(clone)
clone.global_transform = self.global_transform
clone.scale = Vector3(4, 4, 4)
clone.BULLET_DAMAGE = DAMAGE
func equip_weapon():
if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
is_weapon_enabled = true
return true
if player_node.animation_manager.current_state == "Idle_unarmed":
player_node.animation_manager.set_animation("Pistol_equip")
return false
func unequip_weapon():
if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
if player_node.animation_manager.current_state != "Pistol_unequip":
player_node.animation_manager.set_animation("Pistol_unequip")
if player_node.animation_manager.current_state == "Idle_unarmed":
is_weapon_enabled = false
return true
else:
return false
让我们回顾一下脚本的工作原理.
首先,我们在脚本中定义一些我们需要的类变量:
DAMAGE
:单个子弹造成的伤害量.IDLE_ANIM_NAME
:手枪空闲动画的名称.FIRE_ANIM_NAME
:手枪的火焰动画的名称.is_weapon_enabled
:用于检查此武器是否正在使用/启用的变量.bullet_scene
:我们之前处理的子弹场景.player_node
:一个容纳Player.gd
的变量.
我们定义大多数这些变量的原因是我们可以在 Player.gd
中使用它们.
我们要做的每一件武器都会有这些变量(减去 bullet_scene
),这样我们在 Player.gd
中就有了一个一致的交互界面.通过在每个武器中使用相同的变量和函数,我们可以与之进行交互,而不必知道我们使用的是哪种武器,这使得代码更加模块化,可以添加武器,而不必改变 Player.gd
中的许多代码,而且能正常工作.
我们可以把所有的代码都写在 Player.gd
中,但这样一来, Player.gd
会随着我们添加武器而变得越来越难管理.通过使用具有一致界面的模块化设计,我们可以使 Player.gd
保持良好和整洁,同时也使添加和删除和修改武器变得更加容易.
在 _ready
中我们简单地将它传递过来.
但有一点值得注意,我们假设我们会在某些时候填写”Player.gd”.
我们假设 Player.gd
会在调用 Weapon_Pistol.gd
中的任何函数之前自行传递.
虽然这可能导致游戏角色没有进入自己的情况(因为我们忘记),但我们必须有一长串 get_parent
调用来遍历场景树以检索游戏角色. 这看起来不太漂亮(``get_parent().get_parent().get_parent()``依此类推)假设我们会记得将自己传递给``Player.gd``中的每个武器,这是相对安全的.
接下来让我们看看``fire_weapon``:
我们做的第一件事就是我们之前制作的子弹场景.
小技巧
通过实例化场景,我们创建了一个新的节点,并持有我们实例化的场景中的所有节点,将有效地克隆了该场景.
然后,我们在当前所在场景的根节点的第一个子节点上添加一个 clone
.这样就把它变成了当前加载场景的根节点的子节点.
换句话说,我们在当前加载/打开的场景中添加一个 clone
作为第一个节点的子节点(无论在场景树的顶部). 如果当前加载/打开的场景是 Testing_Area.tscn
,我们将把 clone
添加为 Testing_Area
的子项,这是该场景中的根节点.
警告
正如下面关于添加声音的章节中提到的,这个方法做了一个假设.将在后面的添加声音一节中解释 第3部分
接下来我们将克隆的全局变换设置为 Pistol_Point
的全局变换.这样做的原因是为了让子弹在手枪的末端产生.
你可以通过点击 AnimationPlayer ,滚动浏览 Pistol_fire
,就可以看到 Pistol_Point
的位置就在手枪的末端.你会发现手枪射击时,位置差不多就在手枪的尾部.
接下来我们将它扩大一倍因为”4”,因为子弹场景默认情况下有点太小了.
然后我们将子弹的伤害(“BULLET_DAMAGE”)设置为单个手枪子弹造成的伤害量(“DAMAGE”).
现在让我们来看看 equip_weapon
:
我们首先要做的是检查动画管理器是否在手枪的闲置动画中.如果在手枪的闲置动画中,我们将 is_weapon_enabled
设置为 true
,并返回 true
,因为手枪已经成功装备.
因为我们知道我们的手枪的”装备”动画会自动转换为手枪的空闲动画,如果我们在手枪的空闲动画中,手枪必须完成播放装备动画.
注解
我们知道这些动画将会转换,因为我们编写了代码以使它们在 Animation_Manager.gd
中转换
接下来我们检查游戏角色是否处于”Idle_unarmed”动画状态. 因为所有非装备动画都会进入这种状态,并且因为任何武器都可以从这种状态装备,所以如果游戏角色处于”Idle_unarmed”状态,我们会将动画更改为”Pistol_equip”.
既然我们知道 Pistol_equip
将转换为 Pistol_idle
,我们不需要再为武器配备额外的处理,但由于我们还没能装备手枪,我们返回 false
.
最后,让我们看看 unequip_weapon
:
unequip_weapon``类似于``equip_weapon
,但我们却反过来检查.
首先我们检查玩家是否处于闲置动画状态,然后检查玩家是否处于 Pistol_unequip
动画状态,如果玩家不在 Pistol_unequip
动画中,就播放 Pistol_unequip
动画.
注解
你可能想知道为什么我们要检查玩家是否在手枪的闲置动画中,然后确保玩家没有在闲置动画后立即解除装备.其原因是,我们可能在极少数情况下在处理 set_animation
之前就调用 unequip_weapon
两次,所以添加了这个额外的检查来确保unquip动画的播放.
接下来我们检查玩家是否处于 Idle_unarmed
,也就是将从 Pistol_unequip
过渡到的动画状态.如果玩家处于 Idle_unarmed
,那么我们将 is_weapon_enabled
设置为 false
,因为不再使用这把武器,并返回 true
,因为已经成功解除了手枪的装备.
如果游戏角色不在”Idle_unarmed”中,我们会返回”false”,因为我们尚未成功装备手枪.
制造另外两种武器
现在我们已经有了手枪所需要的所有代码,接下来添加步枪和刀的代码.
选择 Rifle_Point
( Player
-> Rotation_Helper
-> Gun_Fire_Points
-> Rifle_Point
)并创建一个名为 Weapon_Rifle.gd
的新脚本,然后添加 下列:
extends Spatial
const DAMAGE = 4
const IDLE_ANIM_NAME = "Rifle_idle"
const FIRE_ANIM_NAME = "Rifle_fire"
var is_weapon_enabled = false
var player_node = null
func _ready():
pass
func fire_weapon():
var ray = $Ray_Cast
ray.force_raycast_update()
if ray.is_colliding():
var body = ray.get_collider()
if body == player_node:
pass
elif body.has_method("bullet_hit"):
body.bullet_hit(DAMAGE, ray.global_transform)
func equip_weapon():
if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
is_weapon_enabled = true
return true
if player_node.animation_manager.current_state == "Idle_unarmed":
player_node.animation_manager.set_animation("Rifle_equip")
return false
func unequip_weapon():
if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
if player_node.animation_manager.current_state != "Rifle_unequip":
player_node.animation_manager.set_animation("Rifle_unequip")
if player_node.animation_manager.current_state == "Idle_unarmed":
is_weapon_enabled = false
return true
return false
其中大部分与 Weapon_Pistol.gd
完全相同,所以我们只会看看改变了什么: fire_weapon
.
我们要做的第一件事是获取 Raycast 节点,它是 Rifle_Point
的子节点.
接下来我们使用 force_raycast_update
强制执行 Raycast 更新. 这将迫使 Raycast 在我们调用它时检测碰撞,这意味着我们可以与3D物理世界进行帧完美碰撞检查.
然后我们检查 Raycast 是否与某些东西相撞.
如果 Raycast 与某些东西相撞,我们首先得到它碰撞的碰撞体. 这可以是 StaticBody, RigidBody,或者a KinematicBody.
接下来我们要确保我们碰到的物体不是游戏角色,因为我们(可能)不想让游戏角色有能力在脚下射击.
如果主体不是玩家,我们就检查它是否有一个叫做 bullet_hit
的函数或方法.如果有,我们就调用它,并传入子弹的伤害量 DAMAGE
,以及 Raycast 的全局变换,这样我们就可以知道子弹是从哪个方向发射过来的.
现在我们需要做的就是为刀编写代码.
选择 Knife_Point
( Player
-> Rotation_Helper
-> Gun_Fire_Points
-> Knife_Point
)并创建一个名为 Weapon_Knife.gd
的新脚本,然后添加 下列:
extends Spatial
const DAMAGE = 40
const IDLE_ANIM_NAME = "Knife_idle"
const FIRE_ANIM_NAME = "Knife_fire"
var is_weapon_enabled = false
var player_node = null
func _ready():
pass
func fire_weapon():
var area = $Area
var bodies = area.get_overlapping_bodies()
for body in bodies:
if body == player_node:
continue
if body.has_method("bullet_hit"):
body.bullet_hit(DAMAGE, area.global_transform)
func equip_weapon():
if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
is_weapon_enabled = true
return true
if player_node.animation_manager.current_state == "Idle_unarmed":
player_node.animation_manager.set_animation("Knife_equip")
return false
func unequip_weapon():
if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
player_node.animation_manager.set_animation("Knife_unequip")
if player_node.animation_manager.current_state == "Idle_unarmed":
is_weapon_enabled = false
return true
return false
与 Weapon_Rifle.gd
一样,唯一的区别在于 fire_weapon
,所以让我们看一下:
我们要做的第一件事就是得到 Knife_Point
的 Area 子节点.
接下来我们要使用 get_overlapping_bodies
来获取 Area 内的所有碰撞体.这将返回一个接触到 Area 的所有碰撞体的列表.
我们接下来想要浏览每一个机构.
首先我们检查以确保物体不是游戏角色,因为我们不想让游戏角色能够刺伤自己. 如果物体是游戏角色,我们使用 continue
,所以我们跳过并看着 bodies
中的下一个物体.
如果我们没有跳到下一个物体,我们检查物体是否有 bullet_hit
函数/方法. 如果确实如此,我们称之为,传递单刀划动所造成的伤害量(DAMAGE
)和全局变换 Area.
注解
虽然我们可以尝试计算刀准确击中的粗略位置,但我们不会这样做,因为使用 Area 的位置运行良好,并且计算粗略位置所需的额外时间 每个人都不值得努力.
制造武器
让我们开始在 Player.gd
中使武器运作.
首先,我们先为武器添加一些需要的类变量:
# Place before _ready
var animation_manager
var current_weapon_name = "UNARMED"
var weapons = {"UNARMED":null, "KNIFE":null, "PISTOL":null, "RIFLE":null}
const WEAPON_NUMBER_TO_NAME = {0:"UNARMED", 1:"KNIFE", 2:"PISTOL", 3:"RIFLE"}
const WEAPON_NAME_TO_NUMBER = {"UNARMED":0, "KNIFE":1, "PISTOL":2, "RIFLE":3}
var changing_weapon = false
var changing_weapon_name = "UNARMED"
var health = 100
var UI_status_label
来介绍一下这些新变量的作用:
animation_manager
:这将保存 AnimationPlayer 节点及其脚本,我们之前写过.current_weapon_name
:我们当前使用的武器的名称. 它有四个可能的值:UNARMED
,KNIFE
,PISTOL
和RIFLE
.weapons
: 一个存放所有武器节点的字典.WEAPON_NUMBER_TO_NAME
:允许我们从武器编号转换为其名称的字典. 我们将用它来换武器.WEAPON_NAME_TO_NUMBER
:一个字典,允许我们从武器的名称转换为它的号码. 我们将用它来换武器.changing_weapon
:一个布尔值,用于跟踪我们是否正在改变枪支/武器.changing_weapon_name
:我们想要改变的武器的名称.health
:我们的球员有多少健康. 在本教程的这一部分中,我们将不会使用它.UI_status_label
: 标签显示我们健康状况数值,以及枪和储备的弹药量.
接下来我们需要在 _ready
中添加一些东西. 这是新的 _ready
函数:
func _ready():
camera = $Rotation_Helper/Camera
rotation_helper = $Rotation_Helper
animation_manager = $Rotation_Helper/Model/Animation_Player
animation_manager.callback_function = funcref(self, "fire_bullet")
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
weapons["KNIFE"] = $Rotation_Helper/Gun_Fire_Points/Knife_Point
weapons["PISTOL"] = $Rotation_Helper/Gun_Fire_Points/Pistol_Point
weapons["RIFLE"] = $Rotation_Helper/Gun_Fire_Points/Rifle_Point
var gun_aim_point_pos = $Rotation_Helper/Gun_Aim_Point.global_transform.origin
for weapon in weapons:
var weapon_node = weapons[weapon]
if weapon_node != null:
weapon_node.player_node = self
weapon_node.look_at(gun_aim_point_pos, Vector3(0, 1, 0))
weapon_node.rotate_object_local(Vector3(0, 1, 0), deg2rad(180))
current_weapon_name = "UNARMED"
changing_weapon_name = "UNARMED"
UI_status_label = $HUD/Panel/Gun_label
flashlight = $Rotation_Helper/Flashlight
让我们回顾一下改变了什么.
首先我们得到 AnimationPlayer 节点并将其分配给 animation_manager
变量. 然后我们将回调函数设置为 FuncRef ,它将调用游戏角色的 fire_bullet
函数. 现在我们还没有编写 fire_bullet
函数,但我们很快就会到达那里.
接下来,我们得到所有的武器节点,并将它们分配给 weapons
.这将允许我们只访问武器节点的名称( KNIFE
、 PISTOL
或 RIFLE
).
然后我们得到 Gun_Aim_Point
的全球位置,这样我们就可以旋转游戏角色的武器来瞄准它.
然后我们通过”武器”中的每一件武器.
我们首先得到武器节点. 如果武器节点不是”null”,那么我们将它的 player_node
变量设置为这个脚本(Player.gd
). 然后我们使用 look_at
函数查看 gun_aim_point_pos
,然后在 Y
轴上旋转 180
度.
注解
我们将所有这些武器点在它们的”Y”轴上旋转”180”度,因为我们的相机指向后方. 如果我们没有将所有这些武器点旋转”180”度,那么所有武器都会向后射击.
然后我们将 current_weapon_name
和 changing_weapon_name
设置为 UNARMED
.
最后,我们从我们的HUD获取UI Label .
让我们在 _physics_process
中添加一个新的函数调用,这样就可以更换武器了.下面是新的代码:
func _physics_process(delta):
process_input(delta)
process_movement(delta)
process_changing_weapons(delta)
现在我们将调用 process_changing_weapons
.
现在让我们在 process_input
中添加所有玩家输入时武器的代码.添加以下代码:
# ----------------------------------
# Changing weapons.
var weapon_change_number = WEAPON_NAME_TO_NUMBER[current_weapon_name]
if Input.is_key_pressed(KEY_1):
weapon_change_number = 0
if Input.is_key_pressed(KEY_2):
weapon_change_number = 1
if Input.is_key_pressed(KEY_3):
weapon_change_number = 2
if Input.is_key_pressed(KEY_4):
weapon_change_number = 3
if Input.is_action_just_pressed("shift_weapon_positive"):
weapon_change_number += 1
if Input.is_action_just_pressed("shift_weapon_negative"):
weapon_change_number -= 1
weapon_change_number = clamp(weapon_change_number, 0, WEAPON_NUMBER_TO_NAME.size() - 1)
if changing_weapon == false:
if WEAPON_NUMBER_TO_NAME[weapon_change_number] != current_weapon_name:
changing_weapon_name = WEAPON_NUMBER_TO_NAME[weapon_change_number]
changing_weapon = true
# ----------------------------------
# ----------------------------------
# Firing the weapons
if Input.is_action_pressed("fire"):
if changing_weapon == false:
var current_weapon = weapons[current_weapon_name]
if current_weapon != null:
if animation_manager.current_state == current_weapon.IDLE_ANIM_NAME:
animation_manager.set_animation(current_weapon.FIRE_ANIM_NAME)
# ----------------------------------
先来介绍一下新增的内容,是如何改变武器的.
首先,我们得到当前武器的数字并将其分配给 weapon_change_number
.
然后我们检查是否按下了任何数字键(键1-4). 如果是,我们将 weapon_change_number
设置为该键映射的值.
注解
键1被映射到”0”的原因是因为列表中的第一个元素被映射到零而不是一个. 大多数编程语言中的大多数列表/数组访问器都以 0
而不是 1
开头. 有关详细信息,请参阅https://en.wikipedia.org/wiki/Zero-based\_numbering.
接下来,我们检查 shift_weapon_positive
或r shift_weapon_negative
是否被按下.如果其中之一被按下,就从 weapon_change_number
中添加或减去 1
.
因为游戏角色可能已经在游戏角色拥有的武器数量之外移动了 weapon_change_number
,我们将其钳制,使其不能超过游戏角色拥有的最大武器数量,并确保 weapon_change_number
为”0”. 或者更多.
然后我们检查玩家是否还没有更换武器.如果还没有,就检查玩家要更换的武器是否是新的武器,而不是玩家当前使用的武器.如果玩家要换的武器是新武器,我们就将 changing_weapon_name
设置为 weapon_change_number
的武器,并将 changing_weapon
设置为 true
.
为了发射武器,我们首先检查是否按下了 fire
动作. 然后我们检查确保游戏角色没有更换武器. 接下来,我们获得当前武器的武器节点.
如果当前武器节点不等于 null
,并且玩家处于 IDLE_ANIM_NAME
状态,我们将玩家的动画设置为当前武器的 FIRE_ANIM_NAME
.
接下来我们再加上``process_changing_weapons`` .
添加以下代码:
func process_changing_weapons(delta):
if changing_weapon == true:
var weapon_unequipped = false
var current_weapon = weapons[current_weapon_name]
if current_weapon == null:
weapon_unequipped = true
else:
if current_weapon.is_weapon_enabled == true:
weapon_unequipped = current_weapon.unequip_weapon()
else:
weapon_unequipped = true
if weapon_unequipped == true:
var weapon_equipped = false
var weapon_to_equip = weapons[changing_weapon_name]
if weapon_to_equip == null:
weapon_equipped = true
else:
if weapon_to_equip.is_weapon_enabled == false:
weapon_equipped = weapon_to_equip.equip_weapon()
else:
weapon_equipped = true
if weapon_equipped == true:
changing_weapon = false
current_weapon_name = changing_weapon_name
changing_weapon_name = ""
让我们回顾一下这里发生的事情:
我们要做的第一件事是确保已经收到了更换武器的输入.就是确保 changing_weapon
是 true
.
接下来我们定义一个变量(weapon_unequipped
),这样我们就可以检查当前的武器是否已成功装备.
然后我们从”武器”中获取当前的武器.
如果当前的武器不是 null
,那么我们需要检查该武器是否被启用.如果武器被启用,调用它的 unequip_weapon
函数,这样它就会启动解除装备的动画.如果武器没有启用,我们将 weapon_unequipped
设置为 true
,因为武器已经成功地解除了装备.
如果当前武器是”null”,那么我们可以简单地将 weapon_unequipped
设置为 true
. 我们做这个检查的原因是因为 UNARMED
没有武器脚本/节点,但是 UNARMED
也没有动画,所以我们可以开始装备游戏角色想要改变的武器.
如果游戏角色已成功装备当前武器(weapon_unequipped == true
),我们需要装备新武器.
首先,我们定义一个新变量(weapon_equipped
),用于跟踪游戏角色是否成功装备了新武器.
然后我们得到游戏角色想要改变的武器. 如果游戏角色想要改变的武器不是”空”,那么我们检查它是否被启用. 如果它没有启用,我们称其为 equip_weapon
函数,因此它开始装备武器. 如果武器已启用,我们将 weapon_equipped
设置为 true
.
如果游戏角色想要改变的武器是”null”,我们只需将 weapon_equipped
设置为 true
,因为我们没有”UNARMED”的任何节点/脚本,我们也没有 任何动画.
最后,我们检查玩家是否成功装备了新武器.如果他成功装备了, 把 changing_weapon
设置为 false
, 因为玩家不再更换武器.还将 current_weapon_name
设置为 changing_weapon_name
,因为当前武器已更改,然后将 changing_weapon_name
设置为空字符串.
现在,我们需要为游戏角色增加一个功能,然后游戏角色就可以开始射击武器!
我们需要添加 fire_bullet
,它将由 AnimationPlayer 调用,我们在前面设置的那些点 AnimationPlayer 函数轨道:
func fire_bullet():
if changing_weapon == true:
return
weapons[current_weapon_name].fire_weapon()
让我们回顾一下这个功能的作用:
首先,我们检查玩家是否正在更换武器.如果玩家正在更换武器,不想射击,所以 return
.
小技巧
调用 return
会停止调用函数的其余部分. 在这种情况下,我们不返回变量,因为我们只对不运行其余代码感兴趣,并且因为我们在调用此函数时不会查找返回的变量.
然后我们通过调用它的 fire_weapon
函数来告诉游戏角色正在使用的当前武器.
小技巧
还记得我们如何提到射击动画的速度比其他动画更快吗? 通过改变射击动画速度,您可以改变武器射击子弹的速度!
在我们准备测试新武器之前,还有一些工作要做.
创建一些测试科目
通过转到脚本窗口,单击”文件”,然后选择新脚本来创建新脚本. 将此脚本命名为 RigidBody_hit_test
并确保它扩展 RigidBody.
现在我们需要添加以下代码:
extends RigidBody
const BASE_BULLET_BOOST = 9
func _ready():
pass
func bullet_hit(damage, bullet_global_trans):
var direction_vect = bullet_global_trans.basis.z.normalized() * BASE_BULLET_BOOST
apply_impulse((bullet_global_trans.origin - global_transform.origin).normalized(), direction_vect * damage)
让我们看一下 bullet_hit
的工作原理:
首先,我们得到子弹的前向方向向量. 这样我们可以知道子弹将从哪个方向击中 RigidBody. 我们将使用它来推送 RigidBody 与子弹的方向相同.
注解
我们需要通过 BASE_BULLET_BOOST
来增加方向向量,这样子弹可以打包更多,并以可见的方式移动 RigidBody 节点. 如果在子弹与 RigidBody 发生冲突时想要更少或更多的反应,您可以将 BASE_BULLET_BOOST
设置为更低或更高的值.
然后我们使用 apply_impulse
来施加冲动.
首先,我们需要计算冲动的位置. 因为 apply_impulse
采用相对于 RigidBody 的向量,我们需要计算从 RigidBody 到子弹的距离. 我们通过从子弹的全局原点/位置减去 RigidBody 的全局原点/位置来做到这一点. 这使我们与 RigidBody 到子弹的距离. 我们规范化这个向量,这样对撞机的大小不会影响子弹移动的程度 RigidBody.
最后,我们需要计算出冲力,使用子弹所面向的方向,然后乘以子弹的伤害.这样就能得到一个不错的结果,对于更强的子弹,将得到的结果会更强.
现在需要将这个脚本附加到所有我们想要影响的 RigidBody 节点上.
打开 Testing_Area.tscn
,选择所有以 Cubes
节点为父节点的立方体.
小技巧
如果你选择最上面的立方体,然后按住 Shift 并选择最后一个立方体,Godot会选择其间所有的立方体!
一旦你选择了所有的立方体,在属性面板中向下滚动,直到你到达 “scripts” 部分.点击下拉菜单并选择 “Load”.打开你新创建的 RigidBody_hit_test.gd
脚本.
最后的笔记
那是很多代码!但现在,所有这些都做完了,你可以去测试你的武器了!
现在你应该可以向方块发射任意数量的子弹,它们会随着子弹的碰撞而移动.
在 :ref:`doc_fps_tutorial_part_three`中,我们将为武器添加弹药以及一些声音!
警告
如果你感到迷茫,请一定要再读一遍代码!
您可以在这里下载这个部分的完成项目: Godot_FPS_Part_2.zip