4 Star 33 Fork 2

BLOG / 从Unity到Godot

Create your Gitee Account
Explore and code with more than 12 million developers,Free private repositories !:)
Sign up
This repository doesn't specify license. Please pay attention to the specific project description and its upstream code dependency when using it.
Clone or Download
README.md 26.74 KB
Copy Edit Raw Blame History

从 Unity 到 Godot

最后编写: 2023-1-20 迈向黎明

本文使用 Obsidian 加了一堆插件编写,Git 仓库的显示效果经过代码转换,与 PDF 略有差异(PDF 更好看一些)

你好

想学 Godot 吗,是不是厌倦从 if 讲起的初学者教程?

既然你来看了这篇文章,那么你一定是个 Unity 大佬吧。

那么好,咱们来以 Unity 的角度快速入门 Godot,省去繁琐的基础教程吧。

本文适用对象

  1. 想学 Godot (废话)。
  2. 对 Unity 有一定了解。
  3. 会 C# 基础语法,理解封装、继承、多态即可(或许不理解也行?)。
  4. 启动过 Godot,琢磨时长大于 20 分钟。

Unity 可以不会,但不建议 0 编程基础的看,文中不会着重讲解代码语法和逻辑。

关于

这篇文章看似是教程,其实是我的个人学习笔记,长得比较像教程而已 233。

我对 Godot 了解不深,文章是边学边写的,出现错误感谢指正。我的联系 QQ 2293840045

感谢来自 Godot中文社区 的 Life 指出的问题,现已修改 单例模式实现 章节,添加了 唯一名称 章节。

既然都用 Godot 了,当然要好好体验独特的 GDScript,本篇暂时不涉及 Godot 支持的其他语言。

Godot 支持的语言是真的多,仅我知道的就有 C#、Rust 甚至冷门的 nim

写到一半忽然感叹...上个新坑为啥要用 unity

参考环境

本文写于 2023 年 1 月 20 日,当时我用的是 Unity 2021.3.16Godot v4.0 beta 十几。祝你阅读时 Godot 依旧兼容本文内容。

U 与 G 的重要区别

要说 UG 之间的最大区别,肯定是 Godot 开源、Unity 闭源(大部分),但讲这些对于咱们初学阶段影响不大,所以直接来看 UG 两者构成游戏的方式吧:

一般情况下,Unity 构成游戏的基本元素如下:

  • 场景(Scenes)
  • 预制体、物体(GameObject)
  • 脚本、组件(Component)
  • 资源(Assets)

而 Godot 只有下面这些东西:

  • 节点(Node)
  • 脚本(Script)
  • 资源(Resource)

节点

可以把节点理解成 Unity 的组件,只不过一个游戏物体只能绑定一个组件,通过多个这种单组件物体组合成一个复合物体。

现在假设,要创建一个带有物理效果的小球,并且让它发光,那么:

img

Unity 那边就不解释了。

Godot 中的物体父子关系和 Unity 相同:父物体移动会带着子物体移动。因此,将 CollisionShape、MeshInstance、OmniLight 放到 Rigidbody 的子级中即可构成一个移动的发光小球。

Unity 中,每个物体都必须记录位置、角度、缩放,因此 Transform 是 GameObject 的必备组件。

Godot 中,每个节点也需要记录位置、角度、缩放,因此 Godot 的节点都继承了 Node3DNode2D 节点,也就是 Godot 中记录位置、角度、缩放的东西。

按照 Godot 节点 等于 Unity 仅有一个组件的物体 的逻辑,可以在 Unity 中用下面方式再做一次这个发光小球:

img

当然,把内置组件这样用太奇怪了,但假设是你在 Unity 中开发的某个超高级次世代对话系统:

img

由于系统太牛逼太复杂了,文本框、说话者头像、菜单按钮、特效层都需要单独的组件来控制,那么这样做就好像合理了一些。

脚本

Unity 的脚本也是组件,Godot 则不同。脚本可以绑定在一个节点上,同时一个节点也只能绑定一个脚本。选中任意一个节点,然后看它的属性列表最下面:

img

这个 Script 字段就是这个节点的脚本了。

如果你打开了一个脚本文件,会看到几个函数直接摆放到了文件里,没有 class 啥的,也就是说,Godot 中一个文件就是一个 class。

但是 class 里可以嵌套 class

资源

终于到了熟悉的东西,Godot 的资源类似 Unity,就是放到项目目录里就行了,只不过 Godot 的项目目录更简单。

项目根目录里有一些 . 开头的文件夹,那些地方用来存放项目设置等引擎要用的东西,就类似 Unity 的 Packages、UserSettings 等文件夹,还包含类似 Unity 中的 .meta 文件的东西。

从 C# 到 GDScript

如果你会 Python,那么恭喜你可以开启八倍速模式了,毕竟 GDScript 语法风格极像 Python。

变量、类型

Godot 在使用变量前需要先声明,使用关键字 var

var playerName = "Rika"
var girlfriend = null
print(playerName)
# print 就是 godot 的输出了。
# 不是说好像 python 么,怎么成 c# 了 ?

playerName 变量明显是个字符串类型,在 Godot 中用 String 表示,而 girlfriend 是个空变量。

那么下面来列举一下 Godot 常用变量类型:

类型 解释
String
bool、int、float
Vector2、Vector3、Color
Variant 任意类型
Array 等于 C# 的 List 和 Python 的 []
Dictionary 等于 C# 的 Dictionary 和 Python 的 `{
Object 大多数对象的基类(不是节点的基类)

想要判断一个变量的类型呢:

if typeof("HAHA") == TYPE_STRING :
	pass

类型转换

基本类型的转换同,使用 目标类型(原始数据) 的方式进行转换:

int("666")
float("123.123")

String 不是基本类型,需要使用 str(原始数据) 进行转换:

str(111)

Godot 中的非 String 数据在字符串拼接的时候不能自动转换成 String。

错误:"你有钱:" + 0

正确:"你有钱:" + str(0)

不过好在一般不会这样拼接字符串,而是用下面会介绍的字符串格式化。

强类型变量、类型注解

如果你是第一次接触弱类型语言,可能会感觉有点小爽(并不,语法补全屎一样)。

而我认为,编程界最伟大的两项发明:TypeScriptPython类型注解

如果你也是一个深陷弱类型泥潭的老将,那么太好了,Godot 支持类型注解:

var haha:int = 123
haha = "awd" # Error: String 不能转换成 int 类型。

给变量 haha 后面加了个冒号和 int,那么 haha 变量就成了一个 int 类型的了,他就只能存放整数。

下面多看几个例子:

var arg:int = 22
var items:Array = [1,2,"3",false]
var friends:Array[String] = ["You","Self"]
var KV:Dictionary = {
	"A":1,"B":2
}

其中 friends 变量的类型 Array 后面加了一个 [String],者可以看作是 C# 中的泛型,就是说 friends 必须是字符串组成的数组。

我就奇了怪了,Dictionary 怎么就不支持泛型。

字符串格式化

照搬 Python,在字符串后使用 % 运算符:

"你好 %s,你有 %d 块钱。" % ["Rika",0]
# 结果:你好 Rika,你有 0 块钱

# 如果参数只有一个,% 后面可以不用数组:
"呵呵$s" %s "..." # 呵呵...

继续深度照搬 Python,根据占位符名称进行格式化:

"你好 {name},你有 {money} 块钱。".format({
	"name":"Rika",
	"money":0
})
# 效果同上

代码块

GDScript(和 Python)采用缩进式语法表示代码块,而不是 C# 中的 {} 花括号。

缩进相同且相邻的多行代码就是一个代码块。

假设现在有个 C# 格式的 if:

if(a >= 2){
	print("a 大于等于 2");
	a += 12;
}
print("if end");

转换成 GDScript:

if a >= 2:
	print("a 大于等于 2")
	a += 12
print("if end")

在 GDScript 中,if 语句首先省去了圆括号,然后在条件后面使用一个冒号 : 表示后面是一个语句块,然后下面的 print+= 运算都比 if 语句多缩进了一个 tab,因此他俩是 if 语句块的内容,而最后一个 print 不是。

注意缩进使用的符号,tab 和空格是不一样的,至于使用哪个可以凭喜好。

代码块中的空行和注释可以没有缩进或缩进不同,不影响代码行。

控制语句

控制语句就还是那些,直接来看看各种控制语句的写法吧(python 同学可以直接跳过):

# if .. elif .. else
if a >= b:
	pass
elif a == 0:
	pass
else:
	pass

# while
while 5 != 6:
	pass

# for
var arr = ["A","B","C"]
for i in arr:
	print(i) # 输出 A B C

# range 方法返回一个迭代器
for i in range(3):
	print(i) # 输出 0 1 2

# match ,这就是个不用写 break 的 switch
var a = 2
match(a):
	1:
		print("a 是个 1")
	2:
		print("a 是个 2")
	_:
		print("a 不是 1 也不是 2")

嗯,挺好理解的吧,等等,为什么随便打的占位符 pass 被语法高亮了?

给你看一下屎一样的三元运算符:`print("活着" if hp>0 else "寄了")``

代码块占位符

有时候不知为何要写一些奇怪的东西,例如下面例子:

if hp <= 0:
	# 寄了,怎么办?TODO: 以后再说吧。
else:
	我还活着()

看似没问题,但却迎来了一个错误,因为 if 后面找不到代码块,毕竟注释不算代码。

这个时候就可以先用一个关键字 pass 顶着,表示这是个空代码块:

if hp <= 0:
	# 虽然寄了然后什么也没干,但是不报错了
	pass

当然这个写法不仅用于 if,在循环、方法定义、class 定义等地方都可以用。

方法

之前说过一个脚本就是一个 class,所以可以直接在脚本里定义方法:

# Godot4.0 beta15 版本开始支持中文标识符
func 我这个方法有两个参数(这个是参数, 这个也是参数):
	pass

嗯,学会了吧,现在加上类型注解?:

func 我帮你计算字符串长度(字符串:String) -> int:
	return len(字符串)

嗯,又学会了吧,现在加上可选参数?:

# 可以算个寂寞
func 我帮你计算字符串长度(字符串:String = "") -> int:
	return len(字符串)

嗯,又又学会了吧,暂时想不到还有啥了。

class

定义方式不想解释,直接看代码:

# 使用extends关键字表示继承
class MyNode extends Node:
	class InnerClass:
		func testFunc():
			print('我在里头')
			
	# 比Python高级,在class里面用var定义成员变量
	var value:int
	func testFunc():
		print("我是 MyNode")
		
	func _init(): # 我是构造方法
		print("诞生了一个 MyNode 示例")

	func _to_string() -> String:
		# 我是 ToString
		return "MyNode[value = %d]" % value

采用 类.new() 来实例化:

var myNode:MyNode = MyNode.new() # 诞生了一个 MyNode 示例
myNode.value = 123
print(myNode) # MyNode[value = 123]
var innerClass:MyNode.InnerClass = MyNode.InnerClass.new()

脚本也是class

之前说过脚本也是 class,那么这个 class 叫啥,继承啥?

这些需要在脚本中声明:

extends Node # 继承自 Node
class_name Abc # 叫做 abc

这样操作之后,在其他的脚本里面就可以用 Abc 这个名字指代上面那个脚本了。

Godot 内置编辑器有时会缺少代码补全,遇到没补全的情况,不一定是代码错了,建议运行一下看看是否正常。

脚本

创建脚本 & 应用

Godot 的脚本有两种存储方式,一种和 Unity 相同,作为代码文件存在资源目录里,另一种可以将文本代码直接存储到节点上,省去了在资源中管理脚本的工作,缺点是这样的脚本就不能复用了。

对着一个节点右键,点击添加脚本即可看见创建脚本的窗口:

img

上图是我对着一个 Node2D 节点创建脚本时的弹窗,注意继承选项,也是 Node2D,然后我们点击创建后,就会得到一个继承自 Node2D 的脚本。

为啥要重点说继承自 Node2D 呢,因为此时,这个 Node2D 节点其实就已经不是 Node2D 节点了(我在说什么?),这个节点其实已经变成了咱们这个脚本的实例,就好像在 Unity 中继承自一个组件去写了一个新组件一样。

Godot 的脚本正是采用这种继承方式去访问节点属性的,因此,这里的父类不能乱选,例如不能给一个 Node2D 节点加一个继承自 Rigidbody3D 的脚本。

一般这个父类就选节点本身的类型即可,但如果为了让脚本可以复用在更多节点上,也可以让脚本继承自这些节点的共同父类,当然,这会导致脚本中不能直接访问那些节点子类独有的成员。

继承组件的父类不会导致组件子类丧失效果,感觉他们的关系有点像这样:

img

title: 内置脚本转换成独立脚本 找到节点的 Script 属性,对着后面的脚本右键,选择保存即可。

暴露属性

当初刚会编程去玩 Unity 的时候,最震惊我的事情竟然是......public 的变量能在 Unity 界面上显示出来!

作为同行,咱 Godot 也能,只要在成员定义的 var 关键字前面加上 @export

@export var Name:String = ""
# 开头的 @ 符号是我这个版本的 Godot4 加上的,如果你是 Godot3 或未来的 Godot4,可能需要去掉 @ 符号。

然后在节点属性面板的最上面就能看到:

img

生命周期

关于生命周期应该不用过多解释了,三个常用周期对照表:

Unity 生命周期 Godot 生命周期
Start _ready()
Update _process(delta:float)
FixedUpdate _physics_process(delta:float)

详细的周期可以看官方文档:Node节点 Methods

这些生命周期方法直接写在脚本里就行了,其中两个 process 方法的参数就是两帧间隔时间,也就是 Unity 中的 Time.DeltaTime

信号

Event?!

信号这个名字可能一听就蒙蒙的,其实就是 C# 中的 event,如果做过 WinForm 开发一定非常熟悉。(Godot 做 UI 的时候真的感觉就像在用 WinForm)

Unity 的 UGUIInputSystem 也用信号这个东西,只不过 Unity 里叫 Event,就是这个东西: img

默认布局下,Godot 的信号面板和节点属性面板在同一个位置:

img

点进去就会看到各种密密麻麻的信号,双击一个信号就可以指定连接对象了,和 Unity 基本相同,这里就不继续讲解了。

信号面板旁边还有个分组面板,感觉有点类似 Unity 的 Tag

从代码连接信号

直接代码加注释解释吧:

extends Button

func _ready():
	# 使用 connect 连接信号
	# pressed 信号会在按钮点击时触发
	pressed.connect(self.OnPressed)
	# GDScript 的 self 就是 C# 的 this 关键字

func OnPressed():
	print("Hello")
	pressed.disconnect(self.OnPressed)

pressed 是 Button 的属性,如果有需求,还可以用字符串指定信号名,使用 connect(name, fun)disconnect(name, fun),同时去掉前面的信号属性。

如果脚本就是简单的监听按钮点击,其实可以直接用 _pressed 生命周期方法。

自定义信号

在脚本中使用 signal 关键字定义信号,可选添加小括号与参数类型注解:

extends Button

signal testSignal(a:float)
signal testSignalWithOutParams

func _ready():
	testSignal.connect(OnPressed)

func _process(delta):
	# 触发信号
	testSignal.emit(delta)
	
func OnPressed(v):
	print("Hello %f" % v)

此时再看这个按钮的信号面板:

img

emit 方法不做类型检查,但是若接收端参数不一致会报错。

再来认识个函数:emit(name, args...),通过字符串触发信号,而且后续参数是变长的。

节点

场景呢?预制体呢?

不知读者是否熟悉 html,如今前端框架发达,浏览器切换 url 往往不用刷新页面了,而是只刷新部分 html 标签,我感觉 Godot 就有这种前端的味道。

Godot 中的节点可以保存成资源,和 Unity 的预制体起到同样的作用,对着节点列表中的某个节点右键,即可把节点保存:

img

也可以和 Unity 保存预制体一样,从大纲视图直接拖到左下角的资源视图即可。

注意了,这个东西叫做 把分支保存为场景,也就是说,这个"预制体"可以当作场景来用。(其实这里我觉得没必要这么翻译,直接说 保存节点树 就挺好的)

如果咱把之前的发光小球保存成场景,那么发光小球场景其实就是 Unity 的预制体,创建出来就是生成了一个发光小球。

如果把发光小球滚来滚去的场景保存下来,那么就成了真正的场景,创建出来并把之前的场景删了,那就是切换场景了。

Unity 中也可以在一个场景下,通过实例化和删除物体的方式做到切换地图的效果。

到底谁做根节点

本节标题正是我刚接触 Godot 时的最大疑惑,当要创建一个游戏物体时,我的个人习惯如下:

  • 如果要创建的物体具有物理效果:Rigidbody 做根节点。
  • 如果没有物理效果:随缘。

父级其实也就影响子级移动旋转,因此产生运动的 Rigidbody 做根节点最合适。

不过有一点需要注意,Godot 中某些节点有明确的父子级关系要求,例如 Area3D 必须要求子级有 Collision 类节点,因此创建物体时要熟悉所用到的各种节点,根据他们的依赖关系选择父子级。

获取节点

使用 get_node(节点路径:String) 获取节点,参数是节点路径,这个路径可以是于当前节点的相对路径,也可以是用 /root/ 开头表示绝对路径,现假设有一个这样的场景:

img

在 Player 节点上执行 get_node,下面列举几个节点路径的获取结果:

路径 Node
. Player 自己
Image Player 下面的 Image 节点
./Image 同上
./Area/CollisionShape2D 最里面那个方块图标的节点
/root/Node2D 第一个节点 Node2D
.. Player 的父级,也就是第一个节点 Node2D
../Control 下面那个图标是绿色圈圈的节点

路径不支持任何的模糊匹配。

/root 会得到一个 Window 对象,而不是最顶层的节点。

由于获取节点这个操作太常用了,Godot 就设置了个语法糖,使用美元符号 $ 即可直接代替 get_node 方法调用:

$FirePosition 等于 get_node("FirePosition")

$/root/Node2D/Control 等于 get_node("/root/Node2D/Control")

如果路径包含特殊符号导致语法出错,也可以把 $ 后面的东西用字符串表示:$"../Control" 等于 get_node("../Control")

可以调用其他节点的 get_node,实现从其他位置作为起点获取节点。

唯一名称

可以将场景中名称不重复的节点标记为唯一名称,方便在代码中获取它:

img

之后就能看到整个节点多了一个 % 小标记:

img

在代码中通过 %【节点名称】 语法糖来获取这种拥有唯一名称的节点:

var 我是那个Button = %Button

当然这样的节点不能重名,毕竟他叫唯一名称节点。

感觉类似 html 中的 id 属性。

get_node 方法也支持获取唯一名称节点:get_node("%Button")

godot 3 不支持 % 语法糖,只能用 get_node("%Button")$"%Button"

实例化节点、删除节点

如果只是操作一个简单的节点,可以直接:

# 创建节点
var n = Node2D.new()
# 添加到场景中(作为当前节点的子级)
add_child(n)
# 删除节点
# n.free()

还有个 remove_child 方法也能实现删除节点的效果,但它并不释放内存,还能把节点重新 add 到场景中。

当然我觉得大家更想要 Unity 中实例化预制体那种效果,于是咱么可以这样写:

@export var NewNodes:PackedScene

func Get():
	var n:Sprite2D = NewNodes.instantiate();
	add_child(n)
	# n.free()

PackedScene 类型表示保存在资源里面的节点,它的 instantiate 方法可以把这些节点实例化出来,这个方法的返回值是那堆节点的根节点,上面例子中根节点是个 Sprite2D。

API

获取输入

Unity 最近引入了 InputSystem,不过 Godot 的输入系统更类似 Unity 的传统输入。

在 Godot 界面菜单栏中点击【项目】, 点击【项目设置】,进入【键位映射】选项卡,就能看到 Godot 的键位管理界面了。

img

和 Unity 一样,Godot也内置了很多键位映射,不过需要点开右上角的 【Show Built-in Actions】 才能看到。

当你在这里创建好需要的键位后,在代码中使用 Input.get_action_strength("映射名称") 即可获取一个 0 到 1 的 float,也就是按键的状态。

如果想要在 _process 中检测按键刚刚按下或刚刚抬起,也就是 Unity 中的 Input.GetKeyDownGetKeyUp,则使用 Input.is_action_just_pressed("XXX")Input.is_action_just_released("XXX")

Input 里包含很多“人如其名”的方法,这里不一一介绍,比较有意思的是有俩 get_vectorget_axis 方法可以快速获取成对的输入,例如方向移动等。

如果你想完全掌控输入,可以尝试下 _input 生命周期,这可以绕过键位映射这些东西。

持久化数据

使用 FileAccess 类进行文件操作。

在Godot3中可能是File

FileAccess 类的静态方法 open 可用于打开文件:

var f = FileAccess.open("user://test.txt",FileAccess.WRITE)

title: Godot 的文件路径

Godot 采用文件沙箱机制,FileAccess操作的文件路径需要使用 user://res:// 开头来表示用户目录和资源目录,例如上文的 user://test.txt 就是读取用户目录的数据。

res://就是指Godot的项目目录了,这个目录的文件在游戏打包后是只读的。

user:// 在 Windows 下默认是 %APPDATA%\Godot\app_userdata\项目名称 这个目录,可以在项目设置中修改(需要开启项目设置的高级选项):

img

这样的话文件就会存到 %APPDATA%\Hello 目录。

FileAccess 实例有 store_string 方法用来向文件追加字符串,flush 方法将内容写入到磁盘,get_as_text 方法返回整个文件内容,接下来就可以自行发挥存储数据的格式了。

顺便再看一下 Godot 的 JSON 操作吧:

var a = JSON.stringify({
	"haha":false
})
print(a) # {"haha":false}
var t = JSON.parse_string('{"test":123}')
print(t.test) # 123

当然要是觉得纯文本存数据不好用,也可以尝试带有类型的存储方式,FileAccess 有很多 store_ 开头的方法用来存储各种类型,以及 get_ 开头的方法读取各种类型数据。

不过感觉用起来不够灵活,我就没详细研究,因此就先不介绍了。

游戏设置

在左上角菜单中点击【项目】【项目设置】即可看到项目设置面板,当然这个界面没什么好讲的,咱来看看怎么用代码访问这里面的选项:

# 这是窗口高度字段的路径
var path = "display/window/size/viewport_height";
# 获取窗口高度
var height:int = ProjectSettings.get_setting(path)
# 设置窗口高度
ProjectSettings.set_setting(path,height + 10)
# 保存设置
ProjectSettings.save()

那么哪个窗口高度字段怎么得到呢?看图:

img

不过,运行之后你会发现窗口并不能变大,这是因为 ProjectSettings 只是存放设置的地方,而不负责这些设置产生的效果。

不过咱已经 save 了修改后的窗口高度,重启游戏后就能看到窗口变大了。

应该你也看到了,项目设置窗口上面有添加删除两个按钮,也就是说你可以在这里添加自己的项目设置。

单例模式实现

来到 GDScript 后某些习惯的东西就不太会写了,其中对于游戏开发最常用的应该就是单例设计模式。

Godot 提供了一种实现单例的方法,点击菜单栏中的【项目】【项目设置】进入【Autoload】选项卡,这里可以添加一些脚本,他们会在游戏开始时自动实例化,并作为 /root 的子节点添加到场景中。

正因为这个脚本要作为子节点添加到场景中,所以他必须要继承自 Node

现在咱来写个单例脚本:

extends Node
class_name TestClass

var myname:String = "Rika"
func hello():
	print("Hello " + myname)

然后添加到 Autoload:

img

现在就可以在代码中使用Autoload 中的名称,也就是TC直接引用这个实例:

print(TC) # TC:<Node#26289897900>
TC.hello() # Hello Rika

要开启Autoload中的全局变量选项才能在代码中直接获取这个实例。

因为这个单例是根节点的子节点,所以也可以通过 get_node("/root/TC") 找到它。

本文后续

文章顶部说过,本文其实不是个真正的教程,只是我的个人学习笔记长得想教程。

因此,本文的后续更新取决于我的学习进度。

如果认为某些内容需要修改,或想给我提出任何建议,欢迎联系我或使用本仓库的 Issue 功能!

目前可能的后续内容:

  • GDScript 反射
  • 插件开发?
  • 试试 Godot C#
1
https://gitee.com/blog_rika/from-unity-to-godot.git
git@gitee.com:blog_rika/from-unity-to-godot.git
blog_rika
from-unity-to-godot
从Unity到Godot
master

Search