项目复盘:Arena Of Furnace

发布于:2025-08-05 ⋅ 阅读:(10) ⋅ 点赞:(0)

前言

        Arena Of Furnace 译为“熔炉竞技场”,其底层含义是生命形态间的融合,为了契合展示 AI 生成3D模型的功能和价值的主题。竞技场则只是为了契合游戏性需求适配的主题。

        游戏开发仅有两人:一个是我,一个是美术同事。虽然后者只参与了美术资源收集的过程,但是还是非常感谢她找到了风格统一的资源。

        游戏采用Godot引擎开发,4.4.1版本。开发语言GDS,没有涉及到C#,因为我当时觉得GDS开发效率比C#高。

        游戏玩法我定义为3D挂机放置,玩家通过“种族,职业,武器”三种原料”,“召唤”出战士参加竞技大会,游戏背景至今尚未完善,但是战斗战斗战斗就完事了。战士的属性和技能都是从构成它的原料中随机而来的,而原料中的则是从大池库中指定的。

        原料们除了赋予战士属性和技能还参与构成其生成AI模型时的提示其 Prompt。比如原料:人类,弓箭手,斧头,对应的prompt就是human,archer,axe,当然要连成完整的一句话,比如

A human archer with axe 之类的,其他的原料组合同理。除此之外再无别的用途。

程序

依赖注入的沉思

        Godot中的依赖注入,特指获取其他节点,方式多种多样。有@export直接赋值,@onready加$,@onready加%直接拿。

        @export赋值有一个显著优点就是无需关心生命周期,因为记录在文本资源中了,但是麻烦而且可能会有数据丢失又得重新赋值的风险。而@onready加%应该是加$的上位替代了,后者很容易面临动一处改全部的风险,而%取专有名称确实更加安全不容易出错。

        而@onready的问题就在于它是@onready,所以如果不注意生命周期,很容易就注入失败,提前的提前,忘记的忘记。为此我对这两种依赖注入的方式一直难以权衡。

Autoload与单例

        这绝对不是我第一次写关于这两者的讨论,因为以前大部分都是用C#写Godot,对Autoload的看法并不全面,Autoload跟单例最大的区别是它能加载场景,而且提供对GDS语法层面上的辅助,即Autoload一个场景,设为Global,就可以直接在脚本中用Global代指它,也能直接访问各种单例才有的成员,虽然有时候不知道什么原因可能会没有语法提示。

        这种内置的单例支持对脚本结构的优化有很大的帮助,可以更关心与其他功能的实现,而非单例模式的维护,是一种引擎层面上的单例支持。这种支持其实在C#中同样提供,比如我的VSCode加了拓展也能以全局变量的形式进行访问。

        所以因为利大于弊,我将推翻我之间的论述,主推引擎单例而非程式单例。

资源的静态化与动态化

        何为资源的静态化,我这里指的是以文本资源形式存储的资源即静态化,这些资源就是静态资源,而动态资源则是存在于内存当中,它另外一个叫法就是资源的实例化。

        以文本形式存储的静态资源可以通过编辑器很方便的创造,调整。而动态化资源需要通过代码创造,调整。就我的项目而言,技能作为一种资源存在,这样一来它就能通过两种手段创造,也就是静态和动态。静态手段就是直接在编辑器中新建一堆文本资源,逐个配置其属性;动态手段则是通过运行时读取大文本数据动态生成实例。前者的优势就是速度快,直观,但拓展性差;后者就是截然相反,动态读取只需要改变单个文本的数据即可改变许多单例,但涉及到大文本数据的读取,当数据量高时,可能导致性能问题。还有一个问题就是安全问题,大文本数据的拓展性很大程度依赖于流式导出,就是不以资源类型的形式导出,这样才能方面后续修改,但会增加加密负担,你也不想你的配置文件可以随便被玩家修改吧...

        最后定下了静态的方案,其实是因为时间问题,若是以后技能数量一多起来就又会想到动态化的好了。

引用变更

        在游戏的主要战斗流程中,每个单位都会从自身的技能池中抽一个技能释放,我在设计这些技能时,将其作为一种资源。面对资源,不论是否是Godot的资源,引用问题一定需要注意,比如在开发早期就经常因为引用问题导致技能对错误的目标释放,后面了解到其实是因为引用关系错乱导致的,比如敌我双方都具有同一个技能,所以引用的其实是同一个对象,索敌时会导致对同一个单位释放。

        面对这种情况一开始我是想通过Local_to_scene复制资源解决的,但是这个东西只能在一个场景实例化时自动触发复制,所以OUT了。最后解决的办法其实非常非常简单,就是在调用前清理一下清理和改变一下技能对单位的依赖即可,这句话的重点不是后面,而是前面的“在调用前”。这个调用前很有讲究,前是前多少呢,以我的经验来看,最好就是下一步马上就是调用的那种时刻之前。

        虽然这看着无理取闹,却透露着一个很隐晦的程序逻辑流处理思想,不论中间的处理过程有多复杂,只要最后有一道门能完整隔绝前后的逻辑流,那么就能实现各种不同逻辑安全执行。有没有很耳熟,其实这就是状态机思想。

        在上文中,状态机可以是每个单位,状态则是它们的技能,状态机需要负责维护技能间的隔绝关系,也要负责更新技能的状态。

UI

容器

        容器在Godot中是一系列特殊的UI控件类,其存在目的就是为了给其他UI控件找一个稳定的存在可能方位,即约束UI控件的大概位置,个人常用的有箱式Box,流式Flow,滚动式Scroll。其中比较含糊的应该是流式容器,它其实就是自由的箱式,按顺序填充,像水一样。

        UI控件有叫Container Sizing的属性面板可供调节其在被容器约束时的行为模式,比如是水平填充Horizontal Fill还是垂直以中心缩放Vertical Shrink Center,还是固定收缩占比Stretch Ratio等等。除此之外,UI控件有所谓的“最小尺寸”属性,同样会影响容器对它的约束行为。

        有了约束的自由,才能自由的约束。容器可以帮助我们应对各式各样的分辨率需求,能让我们通过分析预测得到尽可能相近的“生成式UI构型”,也就是通过代码动态生成的UI布局,而非编辑器中预设好的。

        所以在设计UI时,应该合理的按照“设计尺寸”设计UI,对容器如此,对其他UI控件更是如此。

分辨率适配

        修改游戏分辨率是一个常见的需求,Godot的文档其实在这方面有完备的建议。一个有意思的概念叫做“基本尺寸”或者“设计尺寸”或者别的什么叫法,顾名思义就是用作设计基础,比如在一个1920×1080的画面中,我的UI应该如何摆放,这里的1920×1080就是设计尺寸。

        目前的两个缩放模式一个Canvas Item一个Viewport,前者允许改变基本尺寸至目标分辨率,而后者相反,转而将用基本尺寸渲染出来的图像缩放至目标分辨率,这里的目标分辨率是指显示器的分辨率。

        下面是一张3k的游戏图像,来直观的感受两种缩放模式的差异:

设计尺寸:1920×1080,显示器分辨率:3840×2160

当采用Canvas Item模式缩放,并且设计尺寸改为800×600时(图中胶囊体不是同一个):

可以看到3D物体略有走样,而2D的UI没有明显走样。

当采用Viewport模式缩放,并且设计尺寸改为800×600时:

可以看到2D的UI明显走样。这是因为Viewport模式下,管你这个那个的直接全都缩放,而Canvas Item则会以2D为核心去适配分辨率。

        所以一般的游戏都会采用Canvas Item模式以(至少)保证UI的显示质量,而Viewport模式则更可能用于对像素比例要求严苛的像素游戏,因为它不会改变设计尺寸。而想要直观的修改分辨率,可采用修改设计尺寸+Viewport模式的简单组合,在Godot中设计尺寸叫“Content Size”,意为内容大小。

        除此之外还能看到除了图像质量的变化,UI的布局也发生改变,这是因为UI布局此前是按照1920×1080这个设计尺寸设计的,所以其实是没设计好,导致改变设计尺寸时UI布局紊乱。这种情况可以通过使用“容器”们,或者设计可靠的锚点位置避免。

GUI Input

        _gui_input是控件节点特有的关于输入的用户回调,这个回调会发生在大Input之后,其他小Input回调之前,所以对于UI控件来说更适合处理相关输入。对与其他游戏输入,则是Unhandled Input可能更好,因为它会发生在GUI Input之后,不过这也是要看具体游戏开发需求。还有其实一般也不会直接使用这些用户回调去处理输入的啦。

        Godot中输入事件的传播一般是逆深度优先传播,而GUI Input的key事件,也就是键盘或手柄的按钮事件不会传播,仅能由对应的UI控件消耗处理。

主题

        我原本以为主题修改会是一个很麻烦的事情,其实也确实是这样的。不过倒也不复杂,自定义的Godot主题,可以更好的适配期望的UI风格,而非传统默认的简约现代风,不过再怎么改,都还是挺简约的,因为主题只能在原生的风格上做修改,相当于色系搭配的改变,想要更复杂的变化就需要诸多纹理。

UI场景的重复利用

        场景的重复利用其实是Godot核心理念中的一环。而在这次开发中我对这个理念有更加深入的认识:许多情况下,相同或相似的UI们会出现在各式各样的地方,比如一个角色的头像,可以在主菜单,也可以在战斗界面,那么复用它是理所当然的。而有时候,却需要很多类似而有细微差异的UI,比如一个界面中需要显示经验条,血条,蓝条,体力条;另一个不用显示经验条,这时候难道要重新再做一个UI场景吗?显然不是,秉承非必要不干活的开发原则,我们想的当然应该是尽可能的复用场景,比如上面那种情况,可以勾选场景的Editable Children来解构场景,从而使其可以在另一个父场景中编辑,这种情况下会同时保证场景跟随“原体”(锤佬震怒)的变化而变化,又能使父场景对其的修改而修改。

        当然还有另一种极端手法就是把场景在父场景本地实例化,就相当于断掉了其与“原体”的联系,自立门户。

CanvasLayer

        之所以谈到这个东西是因为在使用Godot自动加载UI单例时,因为自动加载都是最开始的,所以如果UI单例的节点是Control这种无法左右输入顺序的节点,就会导致那些UI跟本接受不到任何事件,即使看得到它们。

        所以这是就需要用到CanvasLayer把它们送到一个更高的逻辑层次,才能接收到输入。

你可能也会遇到的Godot小坑

到底什么是资源文件!!!

        目前看来是这几位:.tscn.scn.tres.res

        事情的起因是因为我把一个txt文件想当然地作为资源导出后,发现用FileAccess(专门用于读取文件的类,不论编辑时还是运行时)读不到。猛然一看才发现.txt在Godot眼中根本不算资源文件,所以默认是不导出的,最直观的方法就是在导出设置中选择“导出选定的资源文件”,你就可以看出Godot将什么文件解读为资源文件了,还有一种是直接查看是否有对应的某个Godot类,继承自Resource类,它都继承自资源类了,那它肯定是个资源,比如我最没想到的csv文件,而Godot将其视为translation资源,也就是翻译文件资源,因此默认配置下,csv文件会被视为翻译资源而导出。

        但我们的.txt文件就没有这种待遇了,所以需要各位手动去导出设置里面填一下需要导出的非资源文件。

翻译翻译

        既然都提到翻译了,为了这次游戏的完成度,想当然的不能只给游戏套英文,所以在很短的工期内作了80%的翻译工作。用的是Godot内部支持的csv翻译模式。

        其实就是将翻译文本视作二维数组?通过keys和语言作为下标读取对应的文本。使用方法及其简单直观,这里看看文档就好。

        这里主要提一点动态翻译时的情况,因为AOF算是富文本游戏,也就是字很多的那种游戏(不是galgame啊!),主要集中在卡牌(技能)的功能描述等方面。所以时常会出现“格式化”的操作,换句话说就是通过占位符替换不同的文本 Formatting。在Godot中,动态翻译是很多节点都默认具备的功能,比如Label那些有文本的节点,可以配置其AutoTranslateMode以满足不同的需求。但无一例外地翻译不了替换文本,因为替换文本的“替换”操作都是实时地(新字符串)。所以如果需要动态Formatting,就只能通过代码实现,然而通过代码实现动态翻译就当心不要将翻译文本缓存起来,比如使其常量化或静态化,如果以节省性能为由,也要注意找个时间定期更新缓存起来的翻译文本,比如改变locale时。

        这可能也是为什么 tr() 函数,即翻译函数是非静态函数的原因吧,其用于翻译一个翻译文件中的key到对应locale(地区)的文本字符串。其实翻译服务也是有个静态版本的翻译函数,不过一般用不到。

Viewport Texture的动态性

        如题,viewport的texture是一种特殊的texture会根据viewport动态改变,比如当viewport被隐藏时,它的texture就啥都没有了。所以当你想把viewport的某一帧的texture保留下来时,就需要用重新创建texture的形式,比如ImageTexture.create_from_image这些,用image来生成一个静态的texture,这样才能完整保留当帧内容。

AABB碰撞

        如果只想要简单的碰撞检测而非物理引擎负责,其中一种方法就是采用AABB跟其他几何做碰撞检测。不过检测时需要注意AABB是模型坐标,所以检测时要将检测对象和AABB都转换到同一个坐标系下,比如:

extends MeshInstance3D

func _physics_process(delta: float) -> void:
    var local_aabb1 = target.get_aabb()
	var local_aabb2 = self.get_aabb()
	var global_aabb1 = target.global_transform * local_aabb1
	var global_aabb2 = global_transform * local_aabb2
	if !global_aabb1.intersects(global_aabb2):
		global_position.y -= delta * falling_speed

上述代码实现了如果两物体的模型的AABB没有碰撞,则会一直下落。 其中将两者的AABB都转换到了世界坐标系下,以进行检测。


网站公告

今日签到

点亮在社区的每一天
去签到