Godot游戏开发实践之三:容易被忽视的Resource

一、前言

首先,特大喜讯,奔走相告, Godot 爱好者们又有新的窝了——我们国人自建的 Godot 论坛: Godot中文社区已经正式开放,这里有一手的开发资源,最新的科技动向,开发上有啥问题可以随时发帖,欢迎大家随时到论坛来讨论、交流和学习游戏开发的最新技术。 :grin:

那么,回过头来,今天要探讨的话题是 Godot 中极容易被新手忽视的 Resource 资源类。开发过 Unity 游戏的同学们知道一个叫 ScriptableObject 的很有用的类,它可以用于数据的包装,在不少场合中应该是非常有用的,那么在 Godot 中有没有这个类似的特性呢?嗯,也有,这就是我们今天要谈到的 Resource 资源类型。

官网也有对 Resources 的相关介绍,我们知道场景是不能拖拽的,也是固定不变的,如果要用场景来保存一些普通数据,肯定不太合理,这时候我们可以使用 Resource 资源类。相比 Node 其优点也很明显,使用非常灵活,同样可以编写脚本,可以定义属性和方法,创建资源文件方便,直接拖拽应用即可。 "OK, FINE!" 这些我都会谈到,更重要的是,我今天会利用 Resource 提出一个全新的、灵活的、“强力”解耦的 EventBus 全局事件模式。感兴趣吗?那我们继续。

主要内容: Resource 的相关用法简介
阅读时间: 8 分钟
永久链接: http://liuqingwen.me/2020/08/17/godot-game-devLog-3-talk-about-resource/
系列主页: http://liuqingwen.me/introduction-of-godot-series/

二、正文

Resource 并不神秘,但是很容易被忽视。其实我们平时创建的场景、节点中就包含了各种不同类型的资源文件,官网中的一张图展示了某些节点 Node 和资源 Resource 的关系:

Nodes and Resources

相信上图中的名称都不陌生,游戏场景开发过程中可能会使用上多种资源类型,常见的就有:图片资源、碰撞图形、各种材质、 UI 主题、音频流、渐变、曲线等等,甚至我们常用的 AnimationPlayer 节点中创建的动画,以及 GDScript 脚本、着色器代码也都是资源。

常用资源类型

资源的创建和使用也非常简单,不过,目前在 Godot 3 版本中也存在一些局限性,接下来我们详细聊聊。

Resource 的创建与使用

创建 Resource 资源的方式就有多种,平常都是在 Node 节点的属性面板中直接创建,比如 New 一个玩家的碰撞体图形的形状,或是动画播放器中的各种动画,粒子系统新建的材质等等,这些资源有一个特点:我们开箱即用,很少保存。

资源文件也可以单独创建,假设我们需要创建一个需要在很多地方使用的资源,比如通用的主题资源、字体资源、瓦片地图 TileSet 资源等等,那么我们可以单独创建相应类型的资源文件,保存起来,在不同场景中轻松实现重复利用。在属性面板或者节点属性中都可以新建资源文件:

创建并保存资源文件

新建资源文件后记得保存,保存的文件后缀名一般是 .tres 也有 .res 文件类型的,区别在于以文本格式保存还是二进制文件格式保存:

保存资源为文件

保存好的资源文件我们可以随时修改其相关属性值,双击资源文件即可,另外,也可以创建多个副本,比如字体资源复制( duplicate )一份,然后修改字体大小属性,使用在不同的地方。

资源的使用方式就简单了,可以直接拖拽到对应属性中,也可以在属性下拉列表中点击 Load 加载。系统自带的资源比较齐全,当然我们也可以自定义资源类型。资源从本质上来说仍然是一种脚本文件,创建自定义资源首先需要创建一个继承自 Resource 类的脚本:

# 继承自 Resource 说明这是一个资源脚本
extends Resource
class_name CustomResource, 'res://CustomResource/custom_icon.svg'

# 资源也可以定义普通的属性
export var variable1 := ''
export var variable2 := 0
# ...

# 资源也可以定义一些方法
func printInfo() -> void:
    # ...

在上面新建的代码中我们声明了资源的类名( CustomResource )以及资源的图标( res://CustomResource/custom_icon.svg )。创建好之后,可以在新建资源列表中发现相对应的自定义资源类型,这一系列过程可以参考下图:

创建自定义资源以及资源实例

是不是非常简单?赶紧动手创建一个压压惊。 :joy:

Resource 相关问题与局限

资源的创建和使用确实简单,不过 Godot 3 中对于自定义资源还是有点小坑,这里提出来,希望对新手朋友们有用。

1. 不能使用自定义 Resource 为变量类型

我们创建自定义资源时,可以给资源定义个类名 class_name CustomResource ,但是在代码中确不能定义该类型的资源变量:

var resource1 : Resource # 没问题
var resource2 : CustomResource # 不支持!

上面的代码运行会报错:

built-in:4 - Parse Error: Invalid export type. Only built-in and native resource types can be exported.

避免这个问题的方法就是使用父类型 Resource 作为变量的类型,不过这样会导致在 export 属性中可以赋予任意类型的资源文件,非常不方便、不人道。当然你可以在代码中进行判断:

if resource && resource is CustomResource:
    # 代码...

不过,好消息是这个问题会在 Godot 4.0 中得到解决。

2. 使用 Resouce 要注意资源是引用类型

如果一个资源文件被多个节点使用,这个时候你只要改变了某个节点下该资源的任意一个属性,结果都会导致其他节点下该资源跟随发生变化!

举个例子,游戏资源中有一个 font_resource.res 字体资源文件,当你改变了资源属性中字体的大小后,其他所有使用了该资源的 UI 界面字体都会发生改变。这也是为什么新手们经常会遇到这种情况:创建一个节点,添加碰撞体,新建一个碰撞体图形,设置好之后复制该节点并重命名,修改新碰撞节点的图片和碰撞体图形,莫名发现之前节点的碰撞体图形也发生了改变,其实就是这个原因。 :grin:

所以,在 Godot 中一个小小的变量值改变都需要重新创建一个资源,这也不算什么大问题,我们可以右键资源文件 Duplicate 复制一个,或者使用 Make Unique 方式使指定资源唯一化。

3. 使用 Resouce 要注意避免循环引用

如果你的项目中创建了不少自定义资源文件,自定义资源代码中又引用了其他类型的资源,那么有可能会出现这种错误;

"scene/resources/resource_format_text.cpp:1387 - Circular reference to resource being saved found: 'res://src/.../???.tres' will be null next time it's loaded."

其实循环引用问题( Circular reference )在普通 GD 代码中也会出现,而出现在自定义资源中则会变得难以发觉。解决这个问题的方法就是不要在编辑器中直接给资源赋值,转而在运行时判断然后动态加载 Resource ,示例如下:

export var resource : Resource       # 自定义资源
export var resourceFilePath : String # 资源路径

func method() -> void:
    if resource == null:
        # 运行时加载资源文件
        resource = load(resourceFilePath)
    # 代码...

这种情况应该比较少见,暂时不做深入讨论,后面的文章遇到了再详述,当然,我们翘首以待的 4.0 版本会解决这个问题。

4. 其他的小问题

如果修改资源脚本中的图标或者类名后,其他引用了这个 Resource 的代码就会报错,类似 Resource 类已经损坏,加载不完整之类。重新启动项目就可以了。

有时候还会遇到这种小 BUG :

core/script_language.cpp:244 - Condition "!global_classes.has(p_class)" is true. Returned: String()

有点莫名,也不容易重现,我估计是修改了 Resource 脚本类名引起的,反正重启项目就没事了。 :joy:

这些小问题说明目前 Godot 的资源类型还不够完善, Waiting for Godot 4.0 药到病除,哈哈!

创建 Resource 相当于 DataContainer

创建自定义 Resource 的一个经典用途就是当做数据容器。创建一个个资源文件就相当于创建了一个个数据容器,这些数据容器一般没有其他功能,只是独立保存一些应用数据,不论是修改还是使用都非常方便且灵活。

举个具有实际应用场景的例子,在一个 Player 或者 AI 脚本中,如果存在着大量数据属性,而这些数据属性一般不会发生改变,或者只是一些配置参数,那么我们完全可以将其抽离出来作为一个单独的数据类——这也是《重构-改善既有代码的设计》一书中提倡的重构方式之一。

# 玩家类

export var name := 'player'
export var moveSpeed := 200
export var rotateSpeed := 5
# 其他一些属性...

在 Godot 中这个所谓的单独数据类可以使用内部类进行包装:

# 玩家类

# 内部类
class Data:
    var name := 'player'
    var moveSpeed := 200
    var rotateSpeed := 5
    func _init():
        pass

内部类虽然可以封装数据,但是在脚本范围之外使用则非常蹩脚,也不方便在编辑器中进行编辑,这时候我们可以使用自定义资源类解决这个痛点:

extends Resource

export var name := 'player'
export var moveSpeed := 200
export var rotateSpeed := 5

然后创建单个或者多个资源文件,在编辑器的属性面板中修改对应的属性值,在其他代码中使用起来非常方便:

export var dataResource : Resource = null

fun _ready() -> void:
    if dataResource != null:
        print(dataResource.name, dataResource.moveSpeed, dataResource.rotateSpeed)

作为数据容器和 ScriptableObject 有点类似,接下来我们看 Resource 的另一个非常有用的场景。

用 Resource 创建全局事件的 EventBus

可以说这是本文的重点,目前我还没有看到有任何人在项目中使用过这种方式,且听我慢慢道来~~~

首先,关于 Godot 中的 signal 信号以及观察者模式相信大家都已经驾轻就熟了,一般在游戏开发中我们都会准守 signal up, call down 的准则,即往上层发送信号,往下层直接调用。当游戏变得越来越复杂的时候,信号可能已经充满了整个项目,比如某个多人游戏中信息面板需要接收并显示多种不同类型的信号:玩家按下回车键发送的文字信息、玩家某个战场获得胜利发出的信号、某个玩家退出游戏发出的信号、官方服务器推送的信息等等,因为这些信息发生在不同的场景,处理起来并不简单,我能想到的解决方式有这么几种:

  1. 使用 get_node('../root/node_path') 方式,不推荐并表示强烈谴责,这会造成强耦合,扩展、维护和重构极其困难
  2. 使用 Global AutoLoad ,也就是 Singleton 单例模式,有效解决耦合,但是维护相当困难,牵一发而动全身,调试困难
  3. 使用 Resource 创建相应的事件资源,强力解耦,使用起来非常方便,调试也非常简单,易扩展和维护

关于第二种方式是大家推荐的模式,我在之前的示例中就使用过:(Godot游戏开发实践之一:使用High Level Multiplayer API制作多人游戏(上)), GDQuest 的文档中也介绍了这种模式: https://www.gdquest.com/docs/guidelines/best-practices/godot-gdscript/event-bus/ ,示例代码如下:

# 这是一个 AutoLoad 单例
extends Node
# 可以定义多个通用信号
signal new_message(content)

# 其他代码...

其他场景中使用也非常简单:

# 场景 1 中发送信号:
GameConfig.emit_signal('new_message', '......')

# 场景 2 中接收处理信号:
GameConfig.connect('new_message', self, '_on_NewMessage_arrive')

但是这种方式有一个很大的缺陷:全局引用导致重构困难。因为单例相当于全局模式,任何地方都可以引用,重构时一旦改动单例中某个方法或者属性都有可能引起其他地方因为引用失效而导致运行奔溃,寻找这些引用并不容易,这也为什么 GDQuest 推荐的 EventBus 模式是单独创建的只有信号没有其他代码的脚本文件。

废话一堆,一起来看看利用 Resource 创建的事件模式吧!首先创建一个事件资源:

# 自定义资源
extends Resource
class_name EventResource, 'res://EventResource/event_icon.svg'
# 自定义信号
signal custom_event(type, message)
# 可以定义一些属性
export var type := 'defaultEvent'
# 自定义方法用于发送信号的包装,也可以直接发送信号
func emitSignal(object) -> void:
    self.emit_signal('custom_event', type, object)

接下来,我们可以创建一些事件资源文件,比如 message_event.tres trigger_event.tres ,不同的文件可以更改、配置不同的参数,然后在其他脚本中使用:

export var messageEvent : Resource = null
export var triggerEvent : Resource = null

# 可以使用事件资源侦听事件
func someMethod1() -> void:
    if triggerEvent && triggerEvent is EventResource:
        triggerEvent.connect('custom_event', self, '_onTriggerEventHandler')

# 也可以使用事件资源发送事件
func someMethod2() -> void:
    if messageEvent && messageEvent is EventResource:
            messageEvent.emitSignal(info)

因为这些事件都是资源类型,在节点属性中可以直接拖拽使用,而且可有可无,均不影响整个项目的运行,在本示例中玩家的属性配置如下图:

玩家相关设置

可以看到 Player1 只接收 message_event 事件, Player3 只派发 trigger_event 事件,而 Player2 则无任何配置,可谓一目了然。

总结一下使用 Resource 创建事件的一些优点:

  1. 强力解耦!不依赖其他文件或者脚本、节点,很容易进行重构
  2. 便于调试,代码中只要注意 null 引用即可,删除或者添加相关事件都非常友好
  3. 便于测试,修改事件相关属性值非常方便,一改全改
  4. 可以考虑在大型项目中应用

并没有十全十美的万能解决方案,当然也是有缺点的,比如一堆的只是改变了某一个变量值的 .res 文件等。重要的是,目前还没有实际项目支持这个事件模式,有待大家的开发和探索啊。 :smile:

三、总结

好了,这篇就聊了一个简单的 Resource 话题,希望能给新手朋友们带来一点点帮助,给高手朋友们开拓一点点亮光,那这篇文章也就值了。

记住我们 Godot 爱好者的新家: Godot中文社区 ,欢迎常回家看看!

本篇的 Demo 以及相关代码已经上传到 Github ,地址: https://github.com/spkingr/Godot-Demos , 后续继续更新,原创不易,希望大家喜欢! :smile:

我的博客地址: http://liuqingwen.me ,我的博客即将同步至腾讯云+社区,邀请大家一同入驻: https://cloud.tencent.com/developer/support-plan?invite_code=3sg12o13bvwgc ,欢迎关注我的微信公众号(第一时间更新+游戏开发资源+相关资讯):

IT自学不成才