因为我是边做游戏边学的,所以其实感觉到最先难为我的其实是输入,怎样写出最佳的输入代码。所以我先看输入部分了。
1. InputEvent
通常来说,无论是 OS 还是平台,操作输入都是很复杂的事情。为了让这件事好做一些,一个 InputEvent 的内置类型因此而生。输入事件会穿过引擎,在游戏的各个部分都能够被接收,取决于需求。
这里先从一个小例子开始,这个例子处理输入 esc 退出游戏的情况:
js
func _unhandled_input():
if event is InputEventKey:
if event.pressed and event.keycode == KEY.ESCAPE:
get_tree().quit()
然而,这有点复杂,判断太多了。
使用内置的 InputMap 更好,这个就是在之前教程里讲过的,在项目设置里配置的,然后用 is_action_pressed 调用的,标准处理方式。
InputMap 可以让处理多个 key,并且也方便维护(比如更换触发键,只需要调整项目设置里的就行了)。此外,还能便利地在游戏中更换键位,which 几乎每个现代游戏都必须要有。
1.1 输入机制是如何工作的?
首先,用户给一个输入。然后 OS 获取到输入,传递给窗口,传到 Viewport(root),后面的比较复杂用一张图来说明吧:
其实这个图为了避免太长做了一些修正,下面几个事件是有顺序的而不是并行的。顺序如下:
- 如果 Viewport 是一个窗口,这个输入首先会被用于判断是否是对窗口进行的操作,比如移动窗口,改变窗口尺寸等。
- 好,上一步结束后,如果有一个窗口正处于 focused 状态。那么事件会被送往那个窗口,然后在该窗口中处理。如果没有窗口处于 focused 状态,那就送往当前 viewport 里的节点们,继续下一步。
- 首先,输入会被送往
Node._input()
函数(如果重写了, 且没有被 Node.set_process_input() 禁止的话)。可以在此通过 Viewport.set_input_as_handled() 来阻止事件进一步传播。这一步能够帮助你在进行 GUI 操作前处理输入。 - 接下来,这个输入会被送往 GUI 进行处理,叫做
Control._gui_input()
,此外,gui 还会触发一个信号 gui_input。gui 里面通过 Control.accept_event() 来阻止事件进一步传播。gui 里还可以用 Control.mouse_filter 来控制 gui 是否接受鼠标信号以及是否进一步传播。 - 好的,如果到现在还没有被阻止。那么继续前往
Node._shortcut_input()
,其他表现和_input
基本一样。不过这个只会处理 InputEventKey,InputEventShortcut,InputEventJoypadButton 这三种事件。(像是鼠标事件这种就不行了)。正如名字所示,这个事件一般用于快捷键。 - 如果仍然没有处理完,那么就进入
_unhandled_input()
,一般 gameplay 的输入会放在这里。 - 还没有处理完?接下来还有
Node._unhandled_key_input()
,如名字所示,只处理键盘输入。 - 最终,事件到达最后。会触发 Object Picking。该功能可以在项目设置中配置。会在鼠标指着的对象上(如果有碰撞体)执行
_input_event()
。这个如名字所示,用于鼠标事件。所以如果有这样的需求,就要给这些对象上都加上碰撞体。
那么,节点之间有没有顺序呢?有。输入事件在也会在节点上按照顺序传播。如下图所示:
也就是说从最下面开始,一直到上面,或者说反向深度优先(其实很合理,因为从 canvas 的视角来看,最下面的节点会覆盖上层的节点)。windows,viewport 不管这些 order(如前所示,它们最先跑)。此外,_gui_input
也不满足这些 order。gui 遵循一套自己的方式。
gui 的控制一般要求会很精确,所以只有事件目标的直接祖先会接受该事件。看下图(名字不是顺序,只是我自己测试用的): 如果在 5 上触发事件,只会接着触发 4,不会再传播到 1,2,3 上,因为这些都不是 4 的祖先。也就是说 gui 的更像是前端的冒泡。
需要注意的是,因为 Viewport 不会发送输入到别的 Subviewport,所以要用下面两种方法之一:
- 使用 SubViewportContainer。
- 根据需求,自己实现事件传播。
1.2 输入事件的剖析
输入事件代表的是一个事件,不是游戏场景中具体存在的某个东西。
1.3 action
action 可以把一个或者多个输入事件分组为一个容易理解的标题。这有利于你把输入抽象化。
这样可以:
- 匹配不同的设备:键盘,手柄
- 键位更换
- 手动触发操作
如果要手动触发操作,可以这么弄:
js
var ev = InputEventAction.new()
ev.action = "jump"
ev.pressed = true
Input.parse_input_event(ev)
1.4 输入映射
InputMap,就是你在项目设置里设置的那个。
2. 处理输入的案例
最佳实践来了,太棒了。
2.1 事件与轮询
有时候你希望游戏响应某个特定的时间,比如跳跃。有些时候你希望任何时候只要按下按键就会发生什么,比如移动。第一种情况可以用 _input
函数,第二种情况则可以使用 godot 专门提供的 Input 单例(Input.is_action_pressed() 这种)。我们会重点讨论第一种情况。
2.2 Input Event
注意是 Input Event 不是 InputEvent。Input Event 是 InputEvent 类型的对象(我觉得 Godot 起名真的需要改一下了,真的很混乱各种双关)。这个对象包含事件相关的信息。比如:
js
extends Node
func _input(event):
print(event.as_text())
我们用 InputEventMouseButton 为例,它继承了这么多类:
- InputEvent:基类,不用说了。
- InputEventWithModifiers: 添加了检查修饰符的功能,如 shift。
- InputEventMouse: 添加了鼠标事件的属性与功能。
- InputEventMouseButton:包含鼠标点击的功能与属性。
在使用 Input 的时候,最好文档就放在旁边。检查一下可用属性和方法,总会有惊喜。
如果你尝试使用不存在的属性,比如鼠标事件检查键盘属性。所以最好先测试一下事件的类型:
js
func _input(event):
if event is InputEventMouseButton:
print("mouse button event at ", event.postion)
2.3 输入映射
Godot 提供了很多内置的 action,可以在项目设置->InputMap 里右上角打开显示内置类型。
2.4 捕捉 Action
定义操作后,可以用 is_action_pressed() 等来处理。
js
func _input(event):
if event.is_action_pressed("my_action"):
print("my_action occurred!")
2.5 键盘事件
虽然建议使用 action,但偶尔还是会有检查键盘的时候,比如:
js
func _input(event):
if event is InputEventKey and event.pressed:
if event.keycode == KEY_T:
print("T was pressed")
2.6 键盘重影
键盘上一次性输入太多键会导致一些键位不会被电脑接收到。这个无法解决毕竟是硬件问题。除了高级玩家自己会去买防重影的游戏专用键盘以外没啥办法。当然,因为 wasd,space,enter,方向键这些用的太多了,大部分键盘会刻意在这些键上做防重影。所以把你的键位设置在这几个上面是有很大意义的。
2.7 修饰符
像是 InputEventKey 这种事件也都继承了 InputEventWithModifiers,所以可以用其属性检查修饰符是否按下。
2.8 鼠标事件
鼠标事件分为 InputEventMouseButton 和 InputEventMouseMotion。名字就能看出来区别。
2.8.1 鼠标按钮
鼠标按钮和按键处理很像,可以处理鼠标左右键中键以及额外鼠标按键(一些高级鼠标)。需要注意滚动也算按钮事件。
2.8.2 鼠标运动
鼠标移动就会触发运动事件。
js
extends Node
var dragging = false
var click_radius = 32 # Size of the sprite.
func _input(event):
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if (event.position - $Sprite2D.position).length() < click_radius:
# Start dragging if the click is on the sprite.
if not dragging and event.pressed:
dragging = true
# Stop dragging if the button is released.
if dragging and not event.pressed:
dragging = false
if event is InputEventMouseMotion and dragging:
# While dragging, move the sprite with the mouse.
$Sprite2D.position = event.position
3. 鼠标和输入坐标
这一节是为了纠正关于输入坐标,获取鼠标位置,屏幕分辨率等常见错误。
3.1 硬件显示坐标
如果 UI 非常复杂(编辑器,工具等),才会用到。除此之外没有意义。
3.2 视口 Viewport 显示坐标
Godot 使用 Viewport 中显示内容,并且可以缩放视口。
鼠标事件中的 position 指的就是视口的坐标。或者使用 Viewport.get_mouse_position() 直接获取鼠标位置。
4. 自定义鼠标指针
当然,切换鼠标指针是一个很常见的事。
切换鼠标指针两种方式,项目设置或者脚本,前者更方便但限制较多。
理论上来说,也可以用一个图片跟随鼠标位置切隐藏鼠标光标来做到,但这样会有延迟。还是用标准方法比较好。
项目设置就不说了,很简单。
4.1 脚本设置
脚本除了可以设置一个总体鼠标指针以外,还能设置指针的不同样子(比如链接上变成手型,等等)。如下:
js
extends Node
# Load the custom images for the mouse cursor.
var arrow = load("res://arrow.png")
var beam = load("res://beam.png")
func _ready():
# Changes only the arrow shape of the cursor.
# This is similar to changing it in the project settings.
Input.set_custom_mouse_cursor(arrow)
# Changes a specific shape of the cursor (here, the I-beam shape).
Input.set_custom_mouse_cursor(beam, Input.CURSOR_IBEAM)
5. 手柄,等各种游戏外设
因为有着 SDL ,所以 Godot 支持上百种不同的游戏外设。
不过其中方向盘,踏板等特别专业的设备测试比较少,可能会有 bug。
使用 action 来简化这一切。
5.1 振动
Input.start_joy_vibration 等函数可以控制振动效果。
5.2 deadzone
像是手柄这种,因为其物理特性,其实静止状态的位置并不是 0,0 ,而是有一点点偏离,如果再严重一点这个就叫做手柄飘移。项目设置内默认死区是 0.5,低于这个值不会被读取。
5.3 回声
按住控制器上的某个按钮并不会持续发送输入。键盘才会。要想达成这一效果,要通过代码生成输入事件 + pares_input_event() 来达成。
5.4 窗口焦点
控制器不像键盘,控制器没有窗口焦点,而是会发送到所有窗口。要想解决这个问题,godot 上文档有一串代码可以来控制焦点。。。(为啥不直接做进引擎?不懂。)
5.5 预防进入睡眠模式,屏保等
键盘持续输入会让电脑不进入休眠状态,但是手柄不会,玩着玩着进入睡眠模式或者屏保也太伤了。所以 Godot 默认启动了节能预防。项目设置中能看到。
5.6 其他问题
手柄问题确实很多,godot 上关于这个的 issue 有几十条。慢慢踩坑吧。。。
6. 处理退出
在 pc 端点叉叉会关闭程序。默认操作是退出游戏。
如果你想做点啥事(比如弹出提示框,或者保存啥的),可以按照如下代码:
js
func _notification(what):
if what == NOTIFICATION_WM_CLOSE_REQUEST:
print(1233)
不过这并不会阻止其退出游戏,要想阻止退出游戏,使用 get_tree().set_auto_accept_quit(false)
。
6.1 发送自己的退出通知
虽然可以用 get_tree().quit()
来退出,但这样不会执行上面 notifiction 里的操作。我们希望发送 NOTIFICATION_WM_CLOSE_REQUEST
。所以我们应该这样子写:
js
get_tree().root.propogate_notification(NOTIFICATION_WM_CLOSE_REQUEST)
但是注意,4.0 版本开始,这个仅仅只会发送这个信息,但不会关闭程序,你还要再手动调用 get_tree().quit() 来关闭应用