如何实现接近原生类型的GDExtension类型

之前提到,通过GDExtension实现Godot插件可以真正把类注册到ClassDB中
这样就可以让Godot的类型系统区分不同的类型,但这样还不够
还需要一些额外的知识才可以让实现的类型尽可能接近原生类型

<0x01> 一个小实验

这里使用godot-rust来构建GDExtension

扩展的测试代码

#[derive(GodotClass)]
#[class(init, base=Node)]
struct TestNode{}

#[godot_api]
impl INode for TestNode{
	fn _ready(){
		godot_print!("TestNode Ready");
	}
}

构建后,在Godot中创建一个测试场景,添加TestNode
TestNode中绑定测试脚本代码

extend TestNode

func _ready() -> void:
	print("Script Ready");

运行后发现,只有Script Ready输出,没有TestNode Ready的输出

也就是说脚本的_ready实现会覆盖GDExtension_ready实现
这就不是很妙了,再怎么说自己实现的节点一定是要基于生命周期写自己的实现的
如果用户需要对扩展的节点的_ready进行扩展且不影响原本逻辑,那就需要手动调用基类的_ready
也就是要这样写

extend TestNode

func _ready() -> void:
	super._ready();
	print("Script Ready");

但这样写用户必须这样显式指出,容易遗忘而造成一些非预期的运行结果
并且对于插件来说,节点的逻辑应当是确定的,应该不需要用户额外的控制

<0x02> 通过notification实现生命周期逻辑

那么问题来了,显然一些原生类型也是要基于生命周期实现自己的逻辑的
为什么原生类型的节点脚本不需要显式调用基类的_ready这样的方法呢
难道是godot对原生类型有什么独特的调用吗
经过我对源代码与文档的探索,可以说对,也不对

首先,为什么脚本的_ready会覆盖节点实现的_ready
在Godot中,_ready_process这样的函数都是虚函数,派生类可以有自己的实现
也即这个函数的调用点是可变的,可随程序运行更改
Godot也是通过这样的机制实现了节点可通过脚本来重新定义这些函数的功能

但这样的修改是会覆盖原来的实现的
要解决这个问题,就必须要有另一种调用方式,这就要靠notificatioin机制了
_ready_process这样的函数本身也表示一个事件
Godot会在这些事件触发后,一定会通过一个指定的函数做回调,这个函数是_notification
当然,这个_notification也是虚函数,如果脚本需要修改也是可以的,但一般不会动这个

查看Control类型的源代码
可以发现对于原生类型,生命周期逻辑是写在_notification函数里的
_ready_process这样的函数是空出来没实现的
所以给原生类型写脚本是不需要显式调用基类方法,通过notification机制处理
如果自己的扩展节点实现了这样的机制,也可以做到这样的效果

notification在文档中有提到,但主要就在最佳实践中提到(Godot通知
可以说第一次见确实不知道这个是什么意思,名字很难联想到可以与扩展有关 毕竟这个特性写脚本是不需要的,一般人也不会上来就开始写扩展

<0x03> 如何通过notification实现

首先观察notification的函数签名,这里用godot-rust的api举例

fn on_notification(&mut self, what: ControlNotification) { /*...*/ }

前面的self不用管,这是rust表示调用者自己的写法
后面的what是一个ControlNotification类型的参数
这个what的类型按不同的节点是不同的,这是是Control节点的on_notification
如果是Object类型,那么what就是ObjectNotification
无论是什么类型,其实都是一个枚举类型
比如这里的ControlNotification有以下值

pub enum ControlNotification {
        RESIZED = 40i32, 
        MOUSE_ENTER = 41i32, MOUSE_EXIT = 42i32, MOUSE_ENTER_SELF = 60i32, MOUSE_EXIT_SELF = 61i32, 
        FOCUS_ENTER = 43i32, FOCUS_EXIT = 44i32,
        THEME_CHANGED = 45i32, SCROLL_BEGIN = 47i32, SCROLL_END = 48i32, 
        LAYOUT_DIRECTION_CHANGED = 49i32, TRANSFORM_CHANGED = 2000i32, LOCAL_TRANSFORM_CHANGED = 35i32, DRAW = 30i32, VISIBILITY_CHANGED = 31i32, 
        ENTER_CANVAS = 32i32, EXIT_CANVAS = 33i32, WORLD_2D_CHANGED = 36i32, ENTER_TREE = 10i32, EXIT_TREE = 11i32, MOVED_IN_PARENT = 12i32, 
        READY = 13i32, PAUSED = 14i32, UNPAUSED = 15i32, 
        PHYSICS_PROCESS = 16i32, PROCESS = 17i32, PARENTED = 18i32, UNPARENTED = 19i32, SCENE_INSTANTIATED = 20i32, 
        DRAG_BEGIN = 21i32, DRAG_END = 22i32, PATH_RENAMED = 23i32, CHILD_ORDER_CHANGED = 24i32, 
        INTERNAL_PROCESS = 25i32, INTERNAL_PHYSICS_PROCESS = 26i32, 
        POST_ENTER_TREE = 27i32, DISABLED = 28i32, ENABLED = 29i32, RESET_PHYSICS_INTERPOLATION = 2001i32, 
        EDITOR_PRE_SAVE = 9001i32, EDITOR_POST_SAVE = 9002i32, 
        WM_MOUSE_ENTER = 1002i32, WM_MOUSE_EXIT = 1003i32, WM_WINDOW_FOCUS_IN = 1004i32, WM_WINDOW_FOCUS_OUT = 1005i32, 
        WM_CLOSE_REQUEST = 1006i32, WM_GO_BACK_REQUEST = 1007i32, WM_SIZE_CHANGED = 1008i32, WM_DPI_CHANGE = 1009i32, 
        VP_MOUSE_ENTER = 1010i32, VP_MOUSE_EXIT = 1011i32, WM_POSITION_CHANGED = 1012i32, OS_MEMORY_WARNING = 2009i32, TRANSLATION_CHANGED = 2010i32, 
        WM_ABOUT = 2011i32, CRASH = 2012i32, OS_IME_UPDATE = 2013i32, 
        APPLICATION_RESUMED = 2014i32, APPLICATION_PAUSED = 2015i32, APPLICATION_FOCUS_IN = 2016i32, APPLICATION_FOCUS_OUT = 2017i32, 
        TEXT_SERVER_CHANGED = 2018i32, ACCESSIBILITY_UPDATE = 3000i32, ACCESSIBILITY_INVALIDATE = 3001i32, 
        POSTINITIALIZE = 0i32, PREDELETE = 1i32, EXTENSION_RELOADED = 2i32, 
        Unknown(i32),
}

看着很多值,但里面其实有很多熟悉的名字,比如READYPROCESSENTER_TREE这样的值
实际上,在on_notification里面就靠这样识别是哪个事件的回调

所以对于之前的示例,这样写就可以

#[derive(GodotClass)]
#[class(init, base=Node)]
struct TestNode{}

#[godot_api]
impl INode for TestNode{
	fn on_notification(&mut self, what: NodeNotification){
		match what {
			NodeNotification::READY => {
				godot_print!("TestNode Ready");
			}
			_ => {}
		}
	}
}

这样实现后再附加原来的脚本,脚本的_ready()就不会覆盖节点自己的READY逻辑了
当然还有一个问题,运行后会发现先输出Script Ready然后输出TestNode Ready
这是因为notification毕竟是回调,触发时间在每个事件的最后
在这里就是脚本的_ready完成后才执行节点的NodeNotification::READY
所以对于节点的初始化,应该用ENTER_TREE完成,毕竟到了ready的周期,节点应该已经初始化完毕

番外:这对脚本实现的扩展有什么启示

如果说因为各种原因,扩展不打算通过GDExtension机制实现,这个知识有什么启示
那还是有的,notification机制在C#/GDScript中也是可以实现的
这样实现之后,对于继承类的脚本也不需要显式调用基类方法

这里用GDscript简单实现一下
首先创建addon,添加类型加载

# res://addons/test_plugin/test_plugin.gd
@tool
extends EditorPlugin

func _enable_plugin() -> void:
	# Add autoloads here.
	var script = load("res://addons/test_plugin/test_node.gd");
	add_custom_type("TestNode", "Node", script, Texture2D.new());
	pass

func _disable_plugin() -> void:
	# Remove autoloads here.
	remove_custom_type("TestNode");
	pass

func _enter_tree() -> void:
	# Initialization of the plugin goes here.
	pass

func _exit_tree() -> void:
	# Clean-up of the plugin goes here.
	pass

创建新的类型脚本

# res://addons/test_plugin/test_node.gd
extend Node

func _notification(what: int) -> void:
	if what == NOTIFICATION_READY:
		print("TestNode Ready");

然后新建场景,添加自定义的TestNode,然后扩展脚本

# res://extend_test.gd
extends "res://addons/test_plugin/test_node.gd"

func _ready() -> void:
	print("Script Ready");

运行发现,也能达到之前的效果,先输出Script Ready然后输出TestNode Ready
脚本实现的扩展的功能性问题也就剩类型没法通过ClassDB判断了
不过之前也提到有其他方法判断,可以解决

题外话,这样看的话C#在Godot真就算三等公民了
虽然官方确实在努力支持C#,但论支持程度真的不太行,任重而道远
目前按Godot社区的情况看,GDExtension是大伙都喜欢的方案,基本什么平台都可以导出
而C#总是有这样或那样的限制,做GDExtension又不方便
不负责任的讲,现在Godot对语言的支持,第一等绝对是GDScript,即开即用,支持全部功能
第二等是有良好社区支持GDExtension的语言,比如Rust或者C++,通过GDExtension也可以支持全部功能
然后就是第三等,总是受限的语言,C#属于是确实容易碰壁,其他的语言可能就是不好做GDExtension
不过社区也在努力,对于GDExtension,有Godot-Sharp推进,C#的支持之后有望做成一个拓展包的形式
只能说慢慢来吧,我也在尝试推进Godot上C#的支持,毕竟C#也算是我的白月光语言了

使用 Hugo 构建
主题 StackJimmy 设计