Godot入门到弃坑:给敌人血肉


3楼猫 发布时间:2025-01-21 23:35:50 作者:cameLcAsE Language

现在应该跟这个抽象的敌人说拜拜了。我们来做一个有血有肉的3D角色。
当然,我们做一个第三人称视角的玩家角色,玩家可以直接操作一个看得见身子的角色似乎更有意思。不过相比之下第三人称角色操作和摄像机代码会更加复杂,所以这里暂时先把前面的敌人改了。

3D模型资源

之前我们只是做了个简单的3D模型来象征性地表示一下敌人。很多(应该说大部分吧)3D游戏都会用更精美的3D模型来表示游戏中的虚拟角色和场景。这些模型通常是用各种3D软件来制作然后产生特定格式的文件再导入到游戏引擎中。
Godot支持多种3D资源文件格式,不过首推的是glTF 2.0文件。这是一个由Khronos Group(也是OpenGL、Vulkan等图形API相关标准的维护组织)维护的3D模型数据标准。这种格式的数据主要有两个后缀名,一个是gltf(文本格式)和glb(二进制)。
fbxobj这样的常见格式也有支持。此外也支持直接导入Blender的.blend文件,不过需要在本机上有安装Blender。
如果你有合适的模型或者自己可以做模型也可以导入自己的模型。当然为了方便讲解,这里以一个免费的带动画的小模型作为例子中使用的模型。这里下载。这个模型就是一个Godot机器人的形象。

导入3D模型资源

和导入其它类型的资源一样,导入3D资源只需要简单地把文件拖进来即可。
不过,略微有一点点反直觉的是,3D模型导入后会变成一个场景而不是某种单个的资源。当然仔细想想一个准备就绪的“3D模型”实际上“肯定”不只是一堆网格数据——除非你真的只想要一个只有网格的模型。这样的模型除了模型本身的网格数据,通常还有各种纹理(就是一些让模型表面有颜色、图案的图片)、材质(确定模型表面如何响应光照)等等。
上述的模型导入到Godot之后会出现两个文件,一个是后缀名为.glb的场景,一个是png纹理——这个是Godot自动生成的Mipmap,不用管它。
场景打开之后是这样一个窗口。虽然它也是一个场景的图标但是和我们自己创建的场景还是不一样。这个窗口实际上是调整此文件的导入参数以便于重新导入。我们目前不需要调整这些参数,但是可以简单了解一下它的结构。
左侧的面板中可以看到导入的场景树。从各节点的图标和名称我们就可以略知一二。
AnimationPlayer也是我们的老朋友了。前面的文章已经介绍过,AnimationPlayer本质上就是一个复杂的插值工具,它也不分什么3D/2D。
我们唯一没见过的就是这个Skeleton 3D节点。它是一个骷髅头图标,skeleton就是骨架的意思。之前在介绍动画的文章中我提到可以用tween和AnimationPlayer通过一些简单的插值来制作一些简单的小动画。实际上在3D场景中,我们依然可以用这些技巧而不通过“真正的”3D软件来制作一些简单的小动画——前提是它们足够简单,比如只是简单地旋转某一坨什么的。
要知道,复杂的3D模型是由不计其数的顶点和多边形构成的,我们不可能挨个去控制这些顶点构成的多边形移动、旋转、缩放什么的!好吧,事实上,3D软件说到底就是在做这些事情,但是这不是我们在制作游戏、设计游戏、为游戏玩法写代码时首要应该考虑和操心的。
为了让网格动起来,特别是让一些包括人型模型在内的、生物类模型栩栩如生地动起来,需要用到一种虚拟的骨骼(skeleton)来控制网格以便制作动画,这些骨骼会根据各种参数让和它绑定的网格按照指定的规则动起来。现在在各种3D软件中有广泛的应用。
Godot中的Skeleton 3D节点就是代表这种骨骼,AnimationPlayer中的各种动画其实就是控制这些骨骼制作的。
最后场景的中各个MeshInstance3D节点自然就是真正的“模型”数据了,它构成了模型的网格。

使用导入的模型

为了替换掉之前的敌人模型,我们直接把这个导入的场景加入场景树中即可,删掉之前占位用的简单模型。
你可能需要修改之前的CollisionShape3D的大小、位置来使之与新的模型匹配。
另外,如果你确实需要单独使用导入的场景中的各个东西的话,你可以新建一个场景,加入导入的场景,然后右键Make Local。这样所有的东西就单独拷贝到这个场景了,你就可以单独另存其中的一部分为资源,然后供其它场景使用了。

我前面呢?

如果你的代码和我之前写的代码一样,那么现在运行游戏的话,应该转身看向玩家的敌人结果背对着玩家的摄像机了!
看看模型的正面朝哪个方向呢?
+Z方向!
还记得前面介绍摄像机的“前方”吗?按照Godot采用的惯例,摄像机的前方是-Z方向,Vector3.FORWARD也是-Z方向。但是!模型的前方是+Z方向!Godot希望模型的前方是对着摄像机的视线的。
如果你的代码和我的代码差不多,之前的转向代码是这样的:
transform.looking_at(body.position)
此处的body是玩家角色。looking_at方法实际上有三个参数,只不过后面两个参数都有默认值所以这里省略了。第二个参数指出旋转时的上方,可以用默认的上方。第三个参数是我们这里需要关注的。第三个参数叫use_model_front
,即是否要使用模型的前方(即Vecto3.MODEL_FRONT)。此参数为true时,将采用+Z方向为前方而不是默认的-Z方向。这个参数设置好后,就可以看到期望的结果了。

动起来动起来

有了前面的经验,我们依然可以用2D角色类似的套路,定义一个表示状态的枚举类型然后根据状态来更新动画。
不过3D动画有时候会出现更为复杂的情况,这里我们来学习一个更擅长处理复杂情况的节点来处理我们的动画播放。
AnimationTree(后文中称其为动画树)节点是一个专门负责控制动画播放的节点。可以通过组合各种资源来控制AnimationPlayer中的动画播放、混合等操作。
在场景中加入AnimationTree节点之后,会出现感叹号提示没有设置AnimationPlayer引用。我们刚才看到,这个导入的Godot机器人确实有动画,并且它的场景中也有个AnimationPlayer,我们现在也确实需要拿到它,但是它是作为一个整体节点出现在场景树中的,我们如何拿到它里面的那些东西呢?
不要忘了场景树节点的右键菜单中的Make Local选项。我们可以直接把这个场景展开作为此场景的本地副本(不影响原本导入的场景)。现在就可以引用其中的AnimationPlayer(及其动画)了。选中动画树节点后,在检视面板找到Anim Player属性,这里你可以点击选择,也可以直接从场景树中把AnimationPlayer拖过来。
设置好AnimationPlayer之后仍然还有个感叹号,因为此时动画树的Tree Root(树根)仍然是空的。动画树的节点是派生自AnimationNode的若干类的实例。Godot内置了几种动画节点可选。但是,这里不要选AnimationRootNode,这个节点没有实际功能。这里我们选择AnimationNodeStateMachine(状态机节点)。其它几种带blend字样的主要是用于混合动画的,暂时不涉及。

什么是状态机

状态机(state machine)是一种抽象机器。
状态机由一个(通常是有限的)状态集合和转移函数确定。一个状态机在一个时刻只能处于给定状态集合中的一个状态。一个状态在满足特定条件时可以转换到另一个状态。且通常在特定条件满足时只能转换到同一个状态。
图片借用维基百科

图片借用维基百科

状态机自然可以用有向图表示,但是我们并不一定要用实现图的方式去实现。很多东西都可以抽象成状态机。
实际上我们在2D部分给玩家角色写的代码就是一个有限状态机。我们根据不同状态播放动画,在特定情况下调整状态。这里的状态机动画节点就是控制动画的来回切换。
状态机无论是本身的概念还是实现都比较简单,并且我们还可以让另一个状态机作为一个状态机的状态,这样一来可以构造非常复杂的抽象机器,你可能已经想到了,这也是实现简单游戏AI的方法。

设置动画状态机

选中动画树节点之后,编辑器最下面的面板中就会出动画树编辑器的选项(也只有选中动画树节点的时候才会出现,说实话来回切换有点麻烦)。打开后目前只有一个Start(伪)状态,指明状态机的初始状态。
我们首先配置一个Idle状态作为初始状态,这很合理。在编辑器中右键,Add Animation子菜单中即可选择引用的AnimationPlayer中的动画创建一个状态。这里还可以看到我刚才提到的,你可以新建另一个状态机作为节点。
这里选择Idle动画,一个叫Idle的状态自动创建好了。现在Start状态还没有连接到任何状态所以还不会播放动画。
要连接各个状态,需要选择上方的连接节点,然后点击某个状态节点就可以看到连线出现了,选中另一个状态就连接好了。如果你要回到选择模式,要点那个指针图标回去。
现在点击状态节点上面的播放按钮就可以看到我们的机器人已经在播放Idle动画了。
只有一个Idle动画显然没什么意思,我们再加入一个Run动画。状态机图的动画节点名称默认是动画名称,你可以点击名字修改。自然,我们需要在某些时候让它从Idle状态进入Run状态,然后某些时候又回到Idle状态。连线吧。
现在你的状态机图看起来应该是这样的:

设置转移条件

我们已经将Idle连到了Start上,现在的动画树实际上在开始时会无条件进入Idle状态。除了Start这种特殊状态,如果只是连线而不设置转移条件的话,不仅没什么用,而且你还会看到很鬼畜的效果,不信你可以试一下。
对于转移发生的条件,我们可以像之前那样专门定义一个表示状态的enum,也可以直接根据velocity属性来判断是否在移动。
先打个预防针。Godot的动画树放在现在来看虽然不能说简陋(应该实现的功能还是有),但是它的很多设计和API给人的感觉还是有点别扭。
在状态机图中,连接各状态节点的连线本身也是一个可以配置的对象。点击一条连线,检视面板中展示了相关属性。目前我们首先要关心的是Advance栏:
这不是什么“高级设置”,不要看到它首先就无视了。毕竟它写的是Advance不是Advanced。这里的Advance应当是指“前进”,指状态的变更。
第一个属性为转移的模式。在状态机图中连线的时候,默认设置为自动模式。自动模式的意思其实是,通过下面的Condition和Expression这两个属性来确定是否执行此状态转移。自动模式下,如果Condition(条件)和Expression(表达式)都没设置,那意思就是可以无条件转移到这个状态。
接下来你会遇到第一个别扭,那就是Condition到底应该怎么设置怎么用。条件?我要在这里写个已经定义了的变量名吗?还是说写一个表达式?等一下,不是下面还有个表达式吗?怎么那里还有个&符号?
首先,Condition的类型是StringName,所以有个&符号。其实输入普通的字符串就行了。我建议先看一眼文档里对这个属性的描述再来看我写的,因为我觉得它的描述和代码还是有一定的误导性。
实际上,你在condition处填写的名称,会被变成一个布尔类型的变量,此变量为真时,便会执行此转移。但是!这个生成的变量的名字并不叫你写在这里的名字!
例如,我现在要设置从Idle到Run的转移。我在这里写上moving。那么在代码中,为了执行这一转移,我需要这样写(其中animation_tree是对动画树节点的引用):
animation_tree.set("parameters/conditions/moving", true)
啊?说好的变量呢?这么这么复杂。
首先set是一个定义在超级基类Object上的方法。当然,这个方法本身没什么神奇的,它的作用就是设置属性的值。只不过我们通常都不会这样写而是直接用点和等号。
它的第一个参数就是属性名。但是这里的属性名怎么还有斜杠分开了?是指这个属性其实是parameters.conditions.moving吗?不!这个属性的名字就叫parameters/conditions/moving!你可以print一下animation_treeget_property_list方法返回的所有属性信息,你会看到很多名字是这种款式的属性,都是动画树生成的。
这种特殊的属性名称决定了你没法在代码中直接用点来设置,不信你可以试试。一定程度上可能是为了防止动画树中的变量污染你的名字空间造成冲突,还有个原因我们稍后就会看到。

在编辑器中测试动画状态机

设置好这样一个条件变量后,我们在代码中就可以测试动画了。当然,每次都要启动游戏去测试动画肯定有点麻烦。
在编辑器中,选中动画树节点后,在检视面板中会有Parameters一栏:
这里就出现了刚才在转移条件处写下的一个moving变量。这里还有一个点值得注意。鼠标放到条件变量的名字上会显示它在代码中使用的完整名称:
发现变量找不到或者不确定的时候可以确认一下,然后在检视面板的属性上也可右键复制粘贴属性名(特别是这种动态生成的又臭又长的动画树变量)。
现在在动画树面板激活时,勾选或者不勾选这个变量就可以看到动画的效果了,相对比较方便。

使用表达式转移状态

要从Run转回到Idle,同样要设置合适的条件。条件是什么呢?当然,我们可以又设置一个idle变量,处于moving状态时,当idle为真时就转移回去。没毛病。
这里顺便介绍Expression的用法。如果你发现仅仅是为了转移回去就又定义一个变量有点麻烦,那么可以直接写一个表达式进去。比如,条件表达式设置为“没有moving”。
表达式是一段可以求得值的代码。但是求值要考虑表达式的环境/上下文。比如2 > 1在目前的初等数学或者说常识范围内是公理,它不依赖任何外部状态且恒真。但是velocity > Vector3.ZERO这样的代码只有在一个有velocity属性的地方才是可以求值的。
所以说这个Advance的条件表达式要放在哪里求值?是AnimationTree节点上还是它的上级场景/节点?
答案是这个动画树上。以刚才说的从Run转移回Idle为例。我们要这样写:
!get("parameters/conditions/moving")
这个表达式求值时会在动画树上求值,所以直接调用get方法获得moving的值就好了。取非就表示没在moving时就转移。
需要注意的是,很多时候动画的参数会和游戏玩法的代码中的各个数值交互,所以你很有可能会需要访问动画树上级场景的脚本中的变量。比如我们不用变量直接检查速度:
get_parent().velocity > Vector.ZERO
需要多一个get_parent的调用(或者其它找到指定节点的方法)。

受击动画的问题

现在我假定你已经按照自己的方法配置好了基本的动画状态机,可以实现从Idle到Run的来回转换。
我们之前也介绍了如何实现攻击敌人。敌人被攻击时,在视觉上也应当有相应的动画来表现这一互动。
经过前面的讲解,你应当可以非常自然地想到,可以再加一个Hurt状态来播放Hurt动画。但是这个问题没那么简单。
首先一般来说,敌人只要还没死,那么任何状态都可以被攻击。也就是说,我们需要从每个状态都拉一条线拉到Hurt上。受击动画较短,且只会在被攻击的时候播放,播放完了还得回到之前状态。如果这样我们还得拉很多线回去。这样一来你要处理的情况就翻倍了。
当然,这个问题很好解决。我们其实可以不给一个状态连线,而是在特定情况下直接用代码转移状态。编辑器中的AnimationNodeStateMachine是一种资源,但是且慢,游戏在运行时要操作状态机的话,需要获得用另一个类型表示的对它的引用。
动画树为树中的各动画节点也生成了属性。如果你没给状态机改名字,那么你可以在动画树的parameters一栏里看到一个叫Playback的东西,它在代码中的名字是parameters/playback。通过它拿到一个AnimationNodeStateMachinePlayback类型的对象,表示状态机的播放状态。
这里主要要提的就是它的travel方法。这个方法的作用就是转移到指定方法。如果在状态机图中存在到目标状态的(最短)路径,那么就会播放沿途的动画。要解决前面提到的问题,主要利用travel也可以直接传送(teleport)到一个没有连接的孤岛状态上,这样我们就可以不用挨个连线。
跳转到Hurt状态很容易,但是要回到之前的状态又不那么容易了。要获得当前的状态则需要调用get_currend_node,这样可以记录转移前的状态。但是相关的节点上并没有暴露出状态机发生状态转换时的信号,所以我们只能通过playback的get_current_play_positionget_current_length来判断是否播放完了。也算不上特别好的办法。
然而我们的问题还没完。
首先,这种转移非常突兀。受击动画通常是和其它状态同时发生的。比如可以在移动的时候被打了,那么它应该可以在保持跑动的状态下同时叠加播放受击动画。比如下半身仍然在跑动,但是上半身抖一下什么的。现在的配置,等于是本来在跑,然后被攻击了,转移到受击动画,开始完整播放受击动画,然后又回去。你可以看看自己配置的动画。
另一方面,这个模型自带的Hurt动画,实际上是一个坐在地上垂头丧气的样子。这样一来跑动的时候被打就更突兀了!你很有可能会得到这样的结果:
这显然不太符合预期。

混合动画

接下来要学习的技巧可以同时解决上述所有问题,是非常实际的解决方案。
为了解决这种死板的状态转移,我们要让两个动画相互叠加。这个时候需要用到另一种动画树节点:AnimationNodeBlendTree
现在我们要删掉之前作为动画树根节点的状态机节点,选择一个混合树作为根节点。好在我们目前的状态机不复杂,重做也没啥问题。如果你已经做了更多的状态,那么只能说抱歉了。其实我尝试了一下,这里不太好重复利用之前的状态机节点。混合树中暴露出来的是一个AnimationNodeStateMachinePlayback槽,我把作为根节点的状态机的Playback保存然后填进去好像也加载不出来。有兴趣可以尝试一下这种做法到底行不行。
混合树的界面和状态机有明显不同。混合树只有一个出口节点,并且必须要一个节点的输出节点连接到这个输出节点才能发挥作用。混合树中可以包含多种节点,其中之一就是之前的状态机。我们首先增加一个状态机节点,点击Open Editor(打开编辑器)就是我们已经熟悉的状态机图界面,然后复刻出之前的Idle和Run两个基本状态(不要Hurt)。可以看到编辑状态机节点的内容时,由于当前的动画树已经出现了更复杂的层次(作为根节点的混合树里面还有一个较为复杂的状态机节点),动画树面板上方显示了当前位于树的哪一个层次:
要回到根混合树节点,点击Root即可。此时我们的动画应当回到了刚才只用状态机节点的状态。
接下来,加入OneShot节点,然后将StateMachine节点的输出端口连到OneShot的输入,最后让OneShot的输出连到最终的输出节点的输入端口。
在编辑混合树时,我们要采用一种不同的视角。我们要把这个树(图)看作是动画每一帧要做的事情。从左往右,一个节点的输出可以产生一个中间结果,然后作为另一个节点的输入,最终一直到Output节点就得到了我们在某一帧中看到的动画(其实到这里已经和Unreal的动画蓝图很像了)。

一次性动画

接下来使用OneShot节点解决刚才的问题。OneShot顾名思义是一次性的,使用这个节点混合的动画播放完之后就会结束。我们的受击动画正是这样的一类动画,挨打的时候播一下,然后该怎么样就继续怎么样。这样一来我们就不用把它放到状态机里和其它节点的连线打架了。
前面已经连上了OneShot的输入端口,这里输入的东西会作为基本动作。接下来的要连上shot端口,shot端口中输入的动作就是要播放的一次性动画。这里我们新建Animation节点,就是普通的动画节点。新建之后,点击上面的胶片图标选择Hurt动画:
在动画树的检视面板的Parameters栏中已经可以看到我们给混合树所添加的节点生成的属性。OneShot生成的属性有几种状态。Fire就是播放,Abort就是淡出,FadeOut是淡出。我们在AnimationTree的检视面板中选中Fire就可以看到它的效果,如果同时设置Moving属性,可以看到fire这个OneShot之后会继续跑。也可以直接在动画树编辑器中在各个节点上勾选变量或者选择OneShot的请求来测试动画。
在代码中,要触发OneShot就像这样:
animation_tree.set("parameters/OneShot/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
OneShot是你的具体的OneShot节点的名称,可以在动画树编辑器中点击节点名字修改,request是固定的。这个参数的类型是枚举OneShotRequest,其取值就是在检视面板参数测试部分可以选的三个值。

应用动画到指定骨头

现在我们只是解决了一些状态会造成状态机蜘蛛网的问题,而这个Hurt动画看起来依然不够自然,因为它会整个坐到地上。
我们只能另外去做一个动画吗?不,我们依然可以利用这个现成的动画。
AnimationTree面板中的OneShot节点上还有一个Filter按钮。此按钮会打开一个骨骼树菜单,并带有勾选框。它的作用你可能已经猜到了,就是在混合动画时,仅将此动画应用到指定的骨头上。这样一来,我们就可以做到保持部分骨头按照前一个动画的动作来运动,而指定的骨头则播放这个要混合的动画。
首先我们要看一下当前操作的模型的骨骼结构。我们选中模型的Skeleton3D节点即可在3D视口中看到骨骼的状况,检视面板中也会显示骨骼树:
骨骼(skeleton)是由若干骨头(bone)组成的。
例如我现在只想用Hurt动画“垂头丧气那一部分”,也就是说只想要它下巴掉下来那一部分动画。那么我看到骨骼头部以上的骨头都是连接在spine.004这个骨头上的。
现在回到混合树中,选择编辑过滤器。首先勾选spine.004,然后点击Fill Selected Children(填满选中骨头的子骨头),连接到它身上的所有骨头都会自动勾选。最后勾选左上方的Enable Filtering(启用过滤)即可:
现在随便用Parameters中的选项或者启动游戏测试一下就可以看到效果了。
最后从Hurt变回Run的时候,头动得貌似还是有点,“跳跃”。这里我简单调整一下这个OneShot的淡入淡出(FadeIn、FadeOut),就可以得到一个还将就的效果。
另一方面这个Hurt动画对于受击来说可能有点慢了。此时我们可以根据需要在混合树中加入一个TimeScale节点即可调整播放速度。倍数大于1会加速播放,小于1就是减速。
动画树中还可以自由地组合各种节点来实现更为复杂的动画效果,接下来就该你发挥了。
预祝大家新年快乐。

© 2022 3楼猫 下载APP 站点地图 广告合作:asmrly666@gmail.com