1. 项目概述:为弹幕游戏注入高性能灵魂
如果你正在用Godot 4开发一款弹幕射击(Bullet Hell)游戏,或者任何需要大量动态粒子效果的项目,那么“性能”这个词很可能已经成了你的噩梦。屏幕上同时出现成百上千个子弹或粒子,每一帧都要处理它们的移动、碰撞、动画和生命周期,这对任何游戏引擎都是巨大的挑战。传统的做法,比如为每个子弹实例化一个Area2D或RigidBody2D节点,在Godot中很快就会达到性能瓶颈,帧率骤降,游戏体验变得卡顿不堪。
这正是我当初开发《Moonzel/Godot-PerfBullets》这个GDExtension插件的初衷。作为一个在游戏开发一线摸爬滚打了十多年的老手,我深知在追求华丽视觉效果和保持游戏流畅度之间找到平衡是多么关键。这个插件不是一个简单的脚本集合,而是一个用C++编写的、深度集成到Godot引擎底层的高性能解决方案。它的核心目标只有一个:让你能够轻松地在屏幕上生成并管理数千个子弹或粒子,同时将性能开销降到最低,把宝贵的CPU和GPU资源留给更重要的游戏逻辑和渲染。
简单来说,Godot-PerfBullets为你提供了一套完整的、生产就绪的弹幕生成与管理框架。它通过MultiMeshInstance2D(多网格实例)来批量渲染子弹,用C++直接处理物理查询和逻辑运算,从而绕过了Godot节点树(Scene Tree)在大量对象时的性能开销。无论你是想制作东方Project那样的密集弹幕,还是需要实现大规模的粒子特效(如爆炸、魔法、雨雪),这个插件都能成为你项目中的性能基石。
2. 核心设计思路与架构解析
在深入代码之前,理解这个插件背后的设计哲学至关重要。它没有采用Godot中常见的“一个子弹一个节点”的范式,而是回归了图形编程和游戏开发中一种经典的高性能模式:数据导向设计与实例化渲染。
2.1 为什么选择GDExtension与C++?
首先,插件基于GDExtension,这是Godot 4推出的、用于替代GDNative的官方C++(及其他语言)扩展接口。选择C++而非GDScript或C#,根本原因在于对极致性能的追求。
- 零开销抽象:子弹的移动、碰撞检测、生命周期管理等核心循环逻辑,在C++中可以实现为紧凑的、无虚拟函数调用的循环,编译器能更好地优化。相比之下,通过GDScript调用引擎API会有额外的脚本语言解释开销。
- 直接访问引擎底层:C++通过GDExtension可以直接调用Godot的底层C++ API,例如进行高效的物理空间查询(
PhysicsDirectSpaceState2D),避免了通过脚本层中转的损耗。 - 内存控制:所有子弹的数据(位置、速度、方向等)被紧密地打包在连续的数组(
LocalVector<BulProps>)中,这有利于CPU缓存命中,极大地提升了遍历和计算效率。这种精细的内存布局控制在高级脚本语言中很难实现。
注意:这意味着你需要对C++和Godot的构建流程有基本了解才能修改插件核心。但对于绝大多数使用者来说,预编译好的二进制文件(.dll, .so等)开箱即用,你完全不需要接触C++。
2.2 核心架构:对象池与多网格实例
插件的性能秘诀主要基于两大技术:
对象池(Object Pooling):
- 传统方式:每次发射子弹时
instantiate()一个新节点,击中或出界后queue_free()。频繁的内存分配和释放是性能杀手。 PerfBullets方式:在初始化时(start_node方法),根据poolCount一次性创建好所有子弹的数据结构(BulProps资源数组)和渲染实例(MultiMeshInstance2D的实例)。发射子弹只是从“池”中取出一个闲置对象,激活并设置其属性;子弹消失则是将其状态重置并放回池中。这完全避免了运行时的动态内存分配。
- 传统方式:每次发射子弹时
多网格实例(MultiMeshInstance2D):
- 这是渲染成千上万个相同网格(子弹贴图)的最高效方式。它通过一次绘制调用(Draw Call)就能渲染所有子弹,而不是为每个子弹发起一次调用。插件将每个子弹的位置、旋转、自定义数据(如动画帧索引)通过
MultiMesh的set_instance_transform_2d和set_instance_custom_data进行批量设置。 - 动画通过一个自定义着色器(
PerfBulletsAnimation Shader)实现。着色器根据INSTANCE_CUSTOM数据中的UV偏移量,在单个精灵图集(sprite sheet)上采样不同的帧,从而让每个子弹都能独立播放动画,同时依然享受MultiMesh的批量渲染优势。
- 这是渲染成千上万个相同网格(子弹贴图)的最高效方式。它通过一次绘制调用(Draw Call)就能渲染所有子弹,而不是为每个子弹发起一次调用。插件将每个子弹的位置、旋转、自定义数据(如动画帧索引)通过
2.3 碰撞检测的“去节点化”处理
这是另一个关键设计。插件没有为每个子弹创建Area2D或CollisionShape2D节点。相反:
- 每个子弹在生成时(
_add_shape方法),会根据BulletType中定义的Shape2D资源,在物理服务器(PhysicsServer)中创建一个对应的RID(资源ID)。 - 在每帧的主循环(
_main方法)中,插件遍历所有活动子弹,使用PhysicsDirectSpaceState2D的intersect_shape方法,直接查询该子弹的RID形状在当前位置是否与场景中的物理体或区域发生重叠。 - 这种方式的性能远高于使用完整的节点树进行碰撞检测,因为它省去了节点处理、信号发射等大量开销。
这种架构决定了插件的工作流程:你通过配置Spawner节点和BulletType资源来定义子弹的行为,然后插件在后台以接近原生代码的效率来执行这一切。你的游戏逻辑只需要关心何时触发Spawner,以及如何处理bullet_hit信号。
3. 从零开始:插件完整配置与实操指南
理论说得再多,不如动手配置一遍。下面我将带你完整地走一遍插件的集成与基础使用流程,其中会穿插我实际项目中积累的详细参数解读和避坑经验。
3.1 环境准备与插件安装
- Godot版本确认:确保你使用的是Godot 4.1或4.2版本。插件高度依赖特定版本的GDExtension API,版本不匹配会导致加载失败。
- 渲染器选择:插件仅兼容Mobile或Forward+渲染器。如果你的项目使用的是Compatibility渲染器,需要先在项目设置(Project Settings -> Rendering -> Renderer)中切换。
- 安装插件:
- 从GitHub仓库下载发布版(Release)的ZIP文件。
- 在你的Godot项目根目录下,解压到
addons/文件夹内。最终路径应类似于your_project/addons/PerfBullets/。 - 打开Godot编辑器,进入
项目(Project) -> 项目设置(Project Settings) -> 插件(Plugins)。 - 在列表中找到
PerfBullets,将其状态从Inactive切换为Active。
实操心得:我建议在项目的早期就集成此插件,并确定使用Mobile/Forward+渲染器。中期切换渲染器可能会影响其他已制作的美术效果(如某些着色器),带来额外工作量。
3.2 基础场景搭建(六步核心流程)
这是让Spawner工作的最低必要步骤,一步都不能错。
第1步:激活插件如上所述,在项目设置中确保插件已激活。激活后,你可以在节点创建对话框(Add Child Node)中搜索到Spawner、BulletBorder、PatternManager等节点。
第2步:创建容器节点新建一个Node2D(或任何继承自Node2D的节点,如CharacterBody2D)。这将是你的“发射器载体”。例如,你可以将其命名为EnemyBoss或PlayerShooter。
第3步:添加Spawner子节点选中上一步创建的节点,为其添加一个Spawner子节点。Spawner是插件的核心,所有子弹都由此节点管理和生成。
第4步:创建BulletType资源选中Spawner节点,在检查器(Inspector)中找到Bullet Type属性。点击下拉框旁边的[空],选择新建 BulletType。这会在内存中创建一个资源。强烈建议立即将其保存到磁盘:点击Bullet Type属性右侧的向下箭头,选择保存(Save),将其命名为如res://bullets/basic_bullet.tres。这样你可以在多个Spawner间复用。
第5步:配置碰撞形状在刚刚创建(或保存)的BulletType资源上,找到Shape属性。点击[空],选择一个内置的Shape2D,例如CircleShape2D或RectangleShape2D。调整其大小以匹配你的子弹贴图。
collideWithAreas/collideWithBodies: 决定子弹与Area2D还是PhysicsBody2D(如CharacterBody2D)发生碰撞,或两者都碰撞。mask: 设置碰撞层。你需要确保子弹的碰撞层(Mask)与目标物体(如玩家)的碰撞层(Layer)有重叠,碰撞才会被检测到。
第6步:设置子弹纹理在Spawner节点的检查器面板中,找到MultiMeshInstance2D分类(这是一个由插件自动添加的子节点属性)。展开它,找到Texture属性,将你的子弹精灵图(或单张纹理)拖拽进去。
- 如果你的子弹是静态的,使用单张纹理即可。
- 如果你的子弹需要动画,你需要一张精灵图集(sprite sheet)。然后,你需要在
Spawner节点的属性中设置Columns In Atlas(图集列数)和Rows In Atlas(图集行数)。动画的播放速度由BulletType中的animationSpeed控制。
完成这六步,一个最基本的子弹发射器就配置好了。运行场景,如果Spawner的Start Mode是ONSTART,你应该能看到子弹被发射出来。
3.3 高级功能配置与优化
基础功能跑通后,我们可以利用插件的高级功能来制作更复杂的弹幕。
3.3.1 使用BulletBorder控制子弹生命周期让子弹飞到屏幕外自动消失是基本需求。手动检测每个子弹的位置并销毁既麻烦又低效。BulletBorder节点就是为此而生。
- 在你的容器节点(第2步创建的
Node2D)下添加一个BulletBorder子节点。 - 为这个
BulletBorder节点添加两个Marker2D(或任何Node2D)作为子节点。分别命名为TopLeft和BottomRight。 - 选中
BulletBorder节点,在检查器中,将Top Left属性指向TopLeft节点,Bottom Right属性指向BottomRight节点。 - 在场景编辑器中,将
TopLeft和BottomRight两个Marker2D分别拖拽到你希望设定的边界矩形的左上角和右下角。通常,这个矩形应该略大于游戏的可视区域(Viewport),这样子弹在刚刚飞出屏幕时就会被回收,玩家看不到它们突然消失。
3.3.2 利用PatternManager编排复杂攻击序列在弹幕游戏中,Boss的攻击往往是多阶段、多波次的。PatternManager让你可以像编排乐谱一样编排弹幕序列。
- 在你的容器节点同级(而不是子级)添加一个
PatternManager节点。它负责管理时序。 - 对于场景中每一个你想通过
PatternManager控制的Spawner,你都需要在PatternManager的Data数组中添加一个PatternSpawnerData资源。 - 在
PatternSpawnerData中:ID: 填写目标Spawner节点上设置的ID(一个整数)。这是两者关联的纽带。Time: 该Spawner在PatternManager启动后,延迟多少秒开始发射。Timer Mode: 选择Physics或Idle,与Spawner自身的Spawner Mode保持一致通常是最稳妥的。
- 将
Spawner节点的Start Mode从ONSTART改为PATTERNMANAGER。 - 当场景运行时,
PatternManager会按照Data数组中的顺序(注意数组顺序!)启动对应的Spawner。
3.3.3 实现追踪(Homing)子弹让子弹追踪玩家是常见的需求。
- 确保你的玩家(或追踪目标)是一个
Node2D(例如CharacterBody2D继承自Node2D)。 - 在
Spawner节点的检查器中,找到Tracked Node属性。将你的玩家节点拖拽赋值给它。 - 勾选
Homing属性。 - 调整
Homing Weight属性。这个值越大,子弹转向目标的速度越快,轨迹越“灵敏”;值越小,转向越平滑,弧度越大。你需要根据子弹速度和游戏手感进行微调。
注意事项:追踪计算每帧都会进行,对大量子弹开启追踪会增加计算量。如果遇到性能问题,可以考虑减少追踪子弹的数量,或使用
Acceleration模拟一个近似追踪的曲线运动。
4. 核心参数深度解析与性能调优
Spawner和BulletType上有大量参数,理解每一个的作用是设计出理想弹幕的关键。下面我将分类详解最重要的几组参数。
4.1 发射控制参数组
这组参数决定了子弹如何从发射器产生。
Fire Rate: 发射间隔(秒)。0.1表示每秒发射10次。注意,每次发射的子弹数量由Bullets Per Radius和Number Of Radii共同决定。Bullets Per Radius:每个发射半径上的子弹数量。这是最容易被误解的参数之一。它不是总子弹数。例如,设置为8,Number Of Radii为1,则每次发射会沿一个半径方向均匀射出8颗子弹。Number Of Radii:发射半径的数量。你可以理解为“同时有几个发射扇面”。例如,Bullets Per Radius为5,Number Of Radii为3,Degrees Between Radii为30,则每次发射会共产生15颗子弹(5*3),它们分布在3条线上,每条线间隔30度。Fire Radius Degrees: 发射扇面的总角度。360度就是全方位发射。90度则是在Start Rotation方向左右各45度的范围内均匀发射。Start Rotation: 发射器的初始角度(度)。0度指向右(X轴正方向)。重要:不要直接旋转Spawner节点!所有旋转都应通过此参数或Start Toward Player等属性控制,因为插件的内部计算依赖于节点的全局位置,而非旋转变换。Pool Count:对象池大小,这是最重要的性能参数之一。它定义了最多可以同时存在多少颗这个Spawner管理的子弹。如果实际需要显示的子弹数超过Pool Count,游戏会崩溃。这个值必须在场景运行前设定,运行时无法修改。我的经验法则是:预估最大同时存在的子弹数,再加20%的余量。设置过大浪费内存,过小会导致崩溃。
4.2 运动与动力学参数组
这组参数控制子弹发射后的行为。
Initial Speed(BulletType): 子弹的初始速度标量。方向由发射角度决定。Acceleration(BulletType): 每帧速度的变化量。正数加速,负数减速。配合Min Speed和Max Speed可以做出先加速后匀速,或逐渐减速停止的效果。Min Speed/Max Speed(BulletType): 速度的上下限。确保子弹不会因持续加速而失控,也不会减速到反向运动。Gravity: 一个施加在子弹上的恒定力向量(通常为Vector2(0, 98)模拟向下重力)。它会影响子弹的轨迹,可以用来制作抛物线弹道。Spin Rate&Spin Acceleration: 让发射器自身旋转。Spin Rate是初始角速度(度/秒),Spin Acceleration是角加速度。两者结合可以产生越来越快或越来越慢的旋转发射效果。Max Spin/Min Spin&Restart At Spin: 为旋转设置边界。当旋转速度达到Max/Min Spin时,如果Restart At Spin为真,Spin Acceleration会反向,形成来回摆动的效果;为假则速度会钳制在边界值。
4.3 高级功能与渲染参数
Move With Parent: 默认为true。如果发射器节点有移动的父节点(比如一个移动的Boss),子弹会随父节点一起移动,保持相对位置。如果设为false,则子弹发射后,其世界坐标就固定了,不再跟随父节点移动。这用于制作从移动物体上发射但轨迹固定的子弹。Spawner Mode:PROCESS还是PHYSICS。这决定了_main逻辑在哪个回调中执行。如果你的游戏逻辑主要在_process中,选PROCESS可以保证同步;如果涉及大量物理交互,选PHYSICS可能更稳定。通常与PatternSpawnerData的Timer Mode保持一致。Columns/Rows In Atlas: 精灵图集的网格划分。必须准确设置,否则动画会错乱。Animation Speed(BulletType): 动画帧切换间隔(秒)。值越小,动画播放越快。
4.4 性能调优实战建议
- 池大小(Pool Count)是根本:时刻监控游戏中同一
Spawner的最大并发子弹数。在开发后期,用get_active_bullet()方法(仅用于调试!)或在bullet_hit信号中打印日志,来统计峰值。然后据此设置Pool Count。 - 碰撞查询优化:
BulletType中的Number Of Queries默认为1。增加此值可以让每帧进行更多次碰撞检测,可能会提高碰撞精度(对于高速子弹),但会显著增加CPU负担。除非必要,否则保持为1。更优的方案是适当增大碰撞形状,而不是增加查询次数。 - 纹理与渲染批次:所有子弹共享同一个
MultiMeshInstance2D的纹理。这意味着:- 使用图集(Atlas)将多种子弹纹理合并到一张大图上,可以让它们在一个绘制批次内完成,这是最佳实践。
- 避免在游戏过程中频繁更换
Spawner的纹理,这可能导致批次中断。
- 减少活动Spawner数量:对于已经发射完毕或暂时不需要的
Spawner,可以考虑将其queue_free()。特别是由PatternManager控制的阶段性攻击,在波次结束后及时清理发射器节点。
5. 信号、交互与游戏逻辑集成
插件通过信号与你的游戏逻辑通信,最重要的是bullet_hit信号。
5.1 处理bullet_hit信号
这是你让子弹与游戏世界交互的桥梁。
- 选中
Spawner节点,切换到Node面板的Signals标签页。 - 找到
bullet_hit信号,双击它。 - 连接到你想要处理碰撞的节点(通常是玩家、敌人或一个全局的游戏管理器)。
- 在连接的函数中,你会收到三个参数:
result: Array: 一个包含碰撞信息的字典数组(与PhysicsDirectSpaceState2D.intersect_shape的返回结果一致),包含了被碰撞的物体(collider)、碰撞法线等详细信息。bullet_index: int: 发生碰撞的子弹在池中的索引。spawner: Spawner: 发射这颗子弹的Spawner节点引用。
一个典型的中弹处理函数(GDScript)如下所示:
func _on_spawner_bullet_hit(result: Array, bullet_index: int, spawner: Spawner): # 1. 获取被击中的物体 var collider = result[0]["collider"] if result.size() > 0 else null # 2. 如果击中玩家 if collider is Player: # 假设你的玩家节点类型为Player var player: Player = collider player.take_damage(10) # 调用玩家受伤方法 # 可以在这里触发击中特效 spawn_hit_effect_at_position(spawner.get_bullet_from_index(bullet_index).position) # 3. 处理子弹本身 if not spawner.return_bullets_to_pool_automatically: # 如果自动回收关闭,需要手动回收子弹 spawner.free_bullet_to_pool(bullet_index) else: # 如果自动回收开启,子弹会在碰撞后自动消失 # 但你仍然可以在这里做一些事情,比如播放音效 $HitSound.play()5.2 手动控制发射(MANUAL模式)
除了自动和模式管理,你还可以完全手动控制发射时机,这非常适合玩家的武器。
- 将
Spawner的Start Mode设置为MANUAL。 - 在代码中,当你需要发射时(例如玩家按下射击键),调用:
这会让$YourSpawnerNode.set_manual_start(true)Spawner立即开始一个发射周期(受NumberOfShots限制)。 - 你可以通过检查
Spawner的属性或连接其信号来判断一次射击是否完成,以便实现连发、冷却等逻辑。
5.3 动态修改子弹属性
通过get_bullet_from_index()方法,你可以在运行时获取并修改特定子弹的属性(如速度、方向),来实现一些特殊效果,比如子弹被击中后分裂、减速或改变追踪目标。
# 假设我们想让索引为5的子弹立即转向一个随机方向 var bullet_props = $Spawner.get_bullet_from_index(5) if bullet_props: var random_dir = Vector2.RIGHT.rotated(randf_range(0, TAU)) bullet_props.direction = random_dir # 注意:直接修改BulProps是有效的,但某些计算(如每帧的速度更新)可能依赖于Spawner的内部状态。 # 最稳妥的方式是通过Spawner提供的方法(如果有)或在下一次_main循环中生效。6. 常见问题排查与实战避坑记录
即使按照指南操作,在实际开发中还是会遇到各种问题。下面是我在多个项目中总结的常见“坑”及其解决方案。
6.1 插件加载失败或节点找不到
- 问题:激活插件后,在节点列表中找不到
Spawner等节点类型。 - 排查:
- 确认Godot版本为4.1或4.2。
- 确认项目渲染器设置为
Mobile或Forward+。 - 检查
addons/PerfBullets目录结构是否完整,特别是PerfBullets.gdextension文件是否存在。 - 查看Godot编辑器底部“输出(Output)”面板,是否有GDExtension加载错误信息。常见原因是依赖的DLL(Windows)或SO(Linux)文件缺失或版本不匹配。
- 解决:重新从官方Release页面下载对应平台和Godot版本的完整插件包,覆盖安装。
6.2 子弹不显示或立即消失
- 问题:运行游戏后,看不到子弹,或者子弹一闪而过。
- 排查:
- 纹理未设置:检查
Spawner节点下MultiMeshInstance2D的Texture属性是否已正确赋值。 - Pool Count为0:确保
Pool Count设置了一个大于0的值。 - BulletBorder设置过小:如果添加了
BulletBorder,并且其矩形区域就在发射器旁边,子弹一出生就可能因出界而被立即回收。检查TopLeft和BottomRight标记的位置。 - MaxLifetime过短:检查
BulletType中的Max Lifetime属性,如果设置得太小(如0.1秒),子弹会很快因寿命到期而消失。 - Start Mode错误:如果
Start Mode是PATTERNMANAGER,但你既没有设置PatternManager,也没有手动触发,那么Spawner永远不会启动。
- 纹理未设置:检查
6.3 没有碰撞检测
- 问题:子弹穿过物体,没有触发
bullet_hit信号。 - 排查:
- 碰撞层/掩码不匹配:这是最常见的原因。确保
BulletType的Mask与目标物体(玩家、敌人)的Collision Layer至少有一位是相同的。Godot的碰撞检测基于层(Layer)和掩码(Mask)的位运算。 - 碰撞形状未设置或太小:确认
BulletType的Shape属性已设置,并且其大小与子弹视觉纹理匹配。一个像素点的碰撞形状很容易错过。 - 碰撞类型错误:子弹要检测
Area2D,需勾选Collide With Areas;要检测PhysicsBody2D,需勾选Collide With Bodies。 - 信号未连接:确认
bullet_hit信号已连接到处理函数,并且函数签名正确。
- 碰撞层/掩码不匹配:这是最常见的原因。确保
6.4 性能突然下降
- 问题:当子弹数量增多时,游戏帧率(FPS)显著降低。
- 排查与优化:
- 使用性能分析器:Godot内置的调试器(Debugger)中的“分析器(Profiler)”标签页是你的第一工具。查看
physics_process和process的耗时,看是否是物理查询或脚本逻辑成了瓶颈。 - 检查Number Of Queries:如果子弹很多,将
Number Of Queries从1增加到2或3会使碰撞检测的CPU开销翻倍或翻三倍。优先考虑增大碰撞形状。 - 减少活动Spawner:是否有旧的、已经发射完毕的
Spawner还在场景中?即使它的子弹池是空的,_main函数仍在每帧运行。不用时应将其queue_free()。 - 简化子弹逻辑:避免在
bullet_hit信号处理函数中执行非常耗时的操作(如复杂的数学运算、实例化大量特效)。可以考虑将特效实例化延迟几帧或使用对象池管理特效。 - 贴图尺寸与格式:确保子弹贴图尺寸合理(例如64x64以内),并使用适合的压缩格式(如2D游戏常用
VRAM Compressed)。
- 使用性能分析器:Godot内置的调试器(Debugger)中的“分析器(Profiler)”标签页是你的第一工具。查看
6.5 动画播放异常
- 问题:子弹的精灵图集动画错乱、闪烁或不播放。
- 排查:
- 行列数设置错误:
Columns In Atlas和Rows In Atlas必须严格对应你精灵图集的网格划分。例如,一个4x4的图集,列数和行数都应设为4。 - 图集UV布局:确保你的精灵图集是规则的网格,并且每个帧的大小完全相同,中间没有间隔。
- Animation Speed为0:如果
Animation Speed设为0,动画将不会更新。设置为一个正数,如0.1(每秒10帧)。
- 行列数设置错误:
6.6 自定义编译插件时的错误
如果你需要修改C++代码并重新编译插件,请严格遵循README中的步骤。最常见的两个错误是:
- SCons编译错误:通常是因为环境变量或编译器路径问题。确保已按照指南安装了
SCons和Mingw-w64,并且在godot-cpp目录下运行scons命令时,使用的是正确的target,platform参数(这些通常在插件的SConstruct文件中已定义好)。 - Godot运行时崩溃:重新编译插件后,打开Godot编辑器或运行项目时崩溃。这几乎总是因为C++代码中存在内存错误(如空指针访问、数组越界)或与Godot引擎的API不兼容。仔细检查你修改的代码,并使用调试器(如GDB)进行跟踪。务必在修改前备份原文件。
最后,再分享一个我个人的小技巧:在开发复杂弹幕模式时,我通常会先创建一个“沙盒”场景。在这个场景里,我会放置一个Spawner,并为其所有关键参数(如Spin Rate,Fire Radius Degrees等)创建Control节点(如HSlider,SpinBox)进行实时联动。这样我就可以在游戏运行中直接拖动滑块来调整弹幕的形状、密度和运动方式,直观地找到最满意的参数组合,极大提升了设计效率。你可以通过@export将Spawner的参数暴露给脚本,然后用set方法在_process中根据UI控件的值实时更新它们。这比反复修改属性、停止、运行游戏要快得多。