3D的CharacterBody
我们之前已经使用过
CharacterBody2D——一个便于创建2D场景角色的节点。
在3D场景中也有对等的CharacterBody3D。我们再一次新建一个Player场景,用CharacterBody3D作为其根节点。模板保持默认,如果默认不是Basic Movement请手动选择,我们稍后在模板代码的基础上修改。
实际上从模板中可以看出,3D的CharacterBody很多属性、方法和2D版本的名字是一样的——只不过相关类型从Vector2变成了Vector3
。
总之模板给我们实现了移动和跳,我们暂时不看它是怎么实现的。其实大部分代码和2D版本差不多。模板默认把移动绑定到了箭头上下左右,跳是空格。按照我的习惯,我选择重新定义相关的输入操作,然后绑定。
类似地,我们需要给它一个CollisionShape,自然,在3D场景中我们需要一个CollisionShape3D。一般来说对于类人形的角色,大家都爱选胶囊状(capsule)的——就是类似于一个球中间拉开了的感觉。
别掉下去了
有可能,你现在启动游戏你的角色会穿过地板。如果你的地板或者其它场景是用CSG搭建的,那么你只需要在检视面板中找到Use Collision勾选启用碰撞即可。
如果你用的是MeshInstance搭建的场景,那么就需要给它套上一个StaticBody3D并配置CollisionShape。所以相比之下CSG确实方便。
摄像机
之前我们把摄像机放在了场景中。
当然,有不少3D游戏的摄像机的位置确实(至少在部分场景中)是固定的。比如早期的《生化危机》和《潜龙谍影》。
不过在接一下来的一系列文章里,我主要以FPS的典型形式来讲解。毕竟FPS从3D游戏诞生之初至今都是非常受欢迎的门类。
对于一个典型的第一人称视角游戏来说,摄像机通常放在直接受(真实世界的)玩家控制的东西上。这个东西作为玩家的化身,它身上的摄像机就相当于我们的眼睛。
因此一般来说我们就把摄像机放在接近这个胶囊顶部的位置。
不过……
前面是哪个前面
要注意Godot的摄像机的“前方”总是朝向z的负方向。你或许会觉得有点别扭,但这样做的原因之一是为了和OpenGL的选择保持一致。
可以看到在往场景中加入Camera3D节点后,摄像机默认朝向-z方向。
基于这样的决定,很多和方向相关的概念都是围绕它来确定的。在Vector3类型上定义了四个常量,FORWARD、BACK、LEFT、RIGHT分别代表前后左右四个方向(四个长度为1的向量)。其中代表前方的FORWARD就是(0, 0, -1),其他方向对应的值也就不言而喻了。
另外,Vector3上还有好几个以MODEL(模型)开头的和方向对应的常量。这是为导入的3D素材选用的坐标系,其实就是和3D模型格式glTF相对应的方向。对于一些有涉及朝向的模型来说,需要注意这些坐标系之间的区别。
旋转的表示
接下来着手实现用鼠标调整摄像机的角度。
你会说OK,很简单,就是调整一下rotation属性呗。
确实没错。但是你很快就会发现事情比想象的复杂。
在2D中,Node2D的rotation属性是一个标量、一个小数而已。因为在一个2D画面中,我们可以想象观察游戏场景的方向(视线方向)始终是穿过屏幕的一个向量。所以物体自身的旋转始终始终是绕这个方向来谈的。
然而在3D空间中,你可以随便点一个Node3D节点看看它的rotation,可以注意到它的类型是Vector3。
想象一下飞机——或者你可以拿一个飞机模型在手上。它自身在3D空间中的“旋转”涉及三种动作:机头抬头低头、机头绕竖直方向左右旋转、“机翼一边抬高的同时另一边降低”。
虽然上面我是用非常随意的语言描述的,但是它们都分别对应着飞机绕不同的坐标轴旋转。用Godot的坐标系来说,分别就对应着绕x轴、y轴、z轴旋转。因此自然而然地,3D空间中的旋转需要用Vector3来描述。
在这样的场景中,我们有更专业的术语来指代这三种旋转(角度)。Pitch(俯仰)、yaw(偏航)、roll(翻滚),这些术语在航空器相关的领域内应该有更专业的中文说法,欢迎相关行业的读者补充。
借用维基百科的图
使用这三个单词除了可以很好地对应约定熟成的术语之外,还有个好处就是它的含义是和具体的坐标系无关的。例如Unreal的API中很多地方就是直接使用这三个词。
控制摄像机
至少近十多二十年典型的FPS操作是用鼠标来控制摄像机以表现“四处看”的感觉。
我们要做的“很简单”,无非是在前后移动鼠标时,让摄像机也跟着调整俯仰角度。在左右移动鼠标时……
这里值得一个停顿。如果不假思索地说在此时让摄像机也调整围绕y轴的旋转角度,那么就会得到一种可能不符合预期的行为——不叫它“问题”是因为在某些类型的游戏中可能就是需要这种行为。
左右移动鼠标时如果只旋转摄像机,那么就会出现视线和玩家角色的前方不在同一直线的问题。在目前主流的FPS中并没有采取这种做法,你肯定知道,我们左右移动鼠标时,整个玩家角色也会同时旋转,然后带动摄像机旋转,进而保持视线前方和玩家角色的前方在同一方向上。另一方面,我们在移动过程中也可以保持移动方向的前方和摄像机的前方指的是同一个方向。
当然,也有一些游戏并不是这样。比如很多有坦克的游戏,鼠标一般是控制炮塔的角度——也同时对应摄像机的角度。而移动是按照坦克底盘的前方来决定方向的。
不过话说回来,典型的FPS操控也是“很假的”。毕竟我们在现实生活中很多时候头部的方向和移动方向都并不一致。但是在快节奏的FPS中,这种真实感很容易让操作变得复杂而影响流畅度。
因此,这里我们希望在左右移动鼠标时直接旋转整个玩家角色。
抓老鼠
我的意思是“捕获鼠标”(capture mouse)。
func _ready() -> void: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
喜报:我换了个软件写,代码样式可以直接复制粘贴到机核的编辑器了,不用发图片了。机核的编辑器真的还有很多改善空间。
默认情况下在游戏里你是看得见鼠标指针,也可以把鼠标指针移动到游戏画面外的。将鼠标模式设置为captured就可以实现一般FPS游戏的效果。
获得鼠标移动
Godot对于鼠标移动的处理目前来看比较奇怪,它没法直接映射到InputMap中。不过经常和鼠标移动对应的手柄摇杆倒是可以——我这里还是以鼠标来讲解。
为此我们需要实现/重写节点的_input(event)方法。要用到这个方法,原因自然是因为鼠标移动没法映射为一个action然后通过Input的静态方法来处理。
但是聪明的你可能会问,如果两者都可以处理输入,那么如何选择呢?
_input方法会在任意输入事件发生时,被调用。Input的相关方法则是我们主动调用的。因此两者首先在执行频率(时机)上有区别。前者和输入事件保持同步,而后者是我们自行控制、主动查询输入事件的发生。
_input唯一的参数是类型为InputEvent的事件对象,其中包含了各种输入事件的属性。由于_input会在任意输入事件发生时执行,所以我们必须对事件进行筛选,以便只在感兴趣的输入事件发生时运行相关代码。
尽早返回是个好习惯,只要不感兴趣就直接return:
func _input(event: InputEvent) -> void: if event is not InputEventMouseMotion: return var mouse_input = event as InputEventMouseMotion var motion = mouse_input.screen_relative # TODO
⚠注意:这段代码中的is not必须要4.3+的版本才能够正常运作。尽管is和not在之前的版本就已经存在,但是在进行类型检查的时候连着这样用在4.3才开始支持。在之前的版本中只能把not写在最前面。
首先,只要不是鼠标移动我就不管(比如按下左键我们暂也时不管)。InputEvent是所有输入事件的基类,鼠标相关事件是InputEventMouse,鼠标移动事件是更具体的InputEventMouseMotion。
InputEventMouseMotion中定义了很多和事件有关的属性,其中screen_relative顾名思义是鼠标移动前后的坐标差值,得到的向量就表示我们鼠标移动的方向和距离。
实际上还存在一个叫relative的属性。不过如果你的游戏画面设置了某种拉伸(stretch,还记得我们在之前的文章中一开始为适应像素游戏风格素材设置画面拉伸模式的时候吗),relative可能会被缩放,进而造成不同分辨率情况下移动鼠标给人的感觉不一样。
基本实现
可以写点控制摄像机的代码了。
按照刚才说的,鼠标前后移动,摄像机也要跟着“俯仰”。因此定义一个look_up方法:
func _input(event: InputEvent) -> void: # ... look_up(motion.y * 0.01 * mouse_sensitivity) func look_up(value: float): camera.rotate_x(deg_to_rad(-value))
毕竟已经写到这里了,很多东西的定义我就不展示了,免得占篇幅。
camera是对摄像机节点的引用。我们在_input中获得的鼠标移动状况会传入到look_up中。
rotate_x方法的名称已经很直白了,就是绕x轴旋转,但是它的参数是以弧度为单位,所以需要转换一下。或者你也可以采用另一种写法:
camera.rotattion_degrees.x -= value
rotation_degrees和rotation是对应的,只不过是以角度表示的,对于人类来说可能方便一点。
mouse_sensitivity是一个export了的整数属性,也就是鼠标灵敏度,我这里默认值设置为50。鼠标移动得到的screen_relative
从一位数到三位数都有可能。如果直接把这个值当成度数传给look_up,我觉得一般人类可能是无法接受那么快的旋转速度的。因此这里乘以一个系数再乘上灵敏度便于调整。当然这里的数字不是什么魔法数,你可以根据自己的需要调整。
现在上下(前后)移动鼠标,可以看到我们的视野跟着在旋转了。
你的问题可能是,value前面这个负号哪来的?
角度的正负
首先,你可以试一下不要这个符号是什么效果。
角度在很多场合中有符号,如果我们把角看成是一条线段/射线/向量绕“起点”旋转出来产生的,那么它的符号体现了角度“是从哪个方向转出来的”。习惯上,你可能接受逆时针为正,顺时针为负。
有可能,这种习惯源自于上学的时候,2D坐标系+x方向朝上,+y方向朝右,一条线段/直线和y轴正方向形成的夹角,其角度我们视作一个正的角度。Godot在2D中以逆时针为负,顺时针为正——但是这和前面提到的习惯来说,也没毛病,毕竟Godot的2D坐标系原点在左上角,即+y方向朝下,+x方向朝右。
在3D中情况还要复杂一些,因为我们有更多的观察角度。例如以围绕x轴的角度为例,如果我们顺着x轴正方向看,那么逆时针为负;如果顺着x轴负方向看,那么逆时针为正。
你可以在场景中使用旋转工具观察一下这些角度的变化。
回到代码的问题。我们的摄像机朝向-z方向。从右边来看(顺着-x方向),“抬头”是逆时针旋转,摄像机的rotation的x分量会加上一个正数。“那为什么value要加负号啊?”,因为InputEventMouseMotion的screen_relative是一个2D向量,其坐标的定义和Godot的2D坐标系是一致的,也就是说鼠标向前是在朝-y方向移动,得到的是一个负数。
限制角度
目前如果持续前后移动鼠标的话,摄像机会360度旋转。无论是从现实还是FPS的典型设计来说,我们都最多只能转一定角度。简单,还是用clamp:
camera.rotation_degrees.x = \ clampf(camera.rotation_degrees.x, -70, 70)
这里的70度是我自己随便选的,你可以自己定义一个export属性。
环顾四周
正如前面所说,鼠标左右移动时,我们不再旋转摄像机,而是直接旋转整个玩家角色,因此定义一个turn方法:
func trun(value: float): rotate_y(-value)
实现它要比俯仰简单得多。并且它是不需要限制角度的,毕竟我们可以转圈。类似地,这里也要加个负号。这个函数放在look_up下方调用就行。
注意,正如前面所说,这里是直接在自己身上调用rotate_y而不是在camera上面。我们是直接转整个角色,而不是旋转摄像机。
移动的实现
模板已经为我们实现了移动的功能。我们简单看一下是如何实现的。
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
主要需要看的就是在_physics_process中这两行代码。
当然首先介绍一个之前在我的代码中没有用到的运算符:=。鉴于GDScript和Python的相似性,需要提醒一句它和Python中的“海象运算符”(walrus operator,横着看)有不同的语义。
在GDScript中这个运算符意味着在变量初始化时,对变量的类型进行推断,这样就不用自己主动写类型。
言归正传。这两行代码要求出想要移动的方向(最终的direction是一个长度为1的向量)。首先是求玩家输入的方向,Input.get_vector根据传入的四个输入操作名称来构造一个2D向量。四个参数分别是-x、+x、-y、+y方向。
实际上如果你接受了摄像机朝-z方向的设定,这里反而就更有道理了。这里的-x方向是左,+y方向是向后。实际上,如果我们从上往下看,让-z为前方(正北),+z和+y方向刚好就和Godot的2D坐标系的+y和+x对齐了!是不是一下就不觉得哪里别扭了。
从上往下看
求得输入的方向后,进行“某种运算”求得角色要移动的方向direction并归一化。这里不得不卖个关子,因为没有一些额外的解释可能无法理解这个“某种运算”怎么就让从输入得到的方向和玩家角色的朝向对齐了。
接下来的代码就没什么新鲜的了。它会根据SPEED的定义来控制移动的速率,然后根据direction的值设置速度,最后调用和CharacterBody2D功能一样的move_and_slide实现移动。
跳跃在模板中也实现了,和2D版基本上一样,不多解释。
至此,一个典型的可以在3D空间中漫游的第一人称角色就基本实现了。