系列更新到第六篇了。前五篇我们陆续把 Godot 的编辑器操作、场景树、脚本基础、物理碰撞和地图搭建都过了一遍,如果一路跟着做下来,你现在应该有了一个能跑动、能跳跃、有地图碰撞的 2D 角色。但说实话,到这一步游戏还只是“能走”,距离“能玩”差着一大截。这一篇我想用一个完整案例,把从输入响应、攻击判定、子弹发射到敌人受伤反馈这一整套核心战斗循环串联起来,让你真正体会到 Godot 做 2D 动作游戏最顺手的生产链路。
这次不打算花大篇幅讲 UI、关卡和美术资源,这些后续系列会单独开篇。我尽量用最简单的节点组合和 GDScript 代码,在 Godot 4.x 环境下落地一套“玩家射击 + 敌人生成 + 打击反馈”的最小可玩战斗 Demo。代码我会一段段拆开讲,也会把我在实际项目里踩过的性能和手感坑一并说出来,希望能帮你少走几步弯路。
1. 战斗循环设计:先把 8 件“破事”理清楚再动手
1.1 一个最小战斗循环到底包含什么
很多刚接触 Godot 的朋友拿到项目第一反应就是打开编辑器开始拖节点、写 AI,结果做到一半发现手感发飘、性能卡顿、代码互相纠缠,最后重写。我自己早期做 2D 游戏时就吃过这种亏,后来养成一个习惯——动手前先在纸上把整条战斗链路列出来,哪怕是一个最简单的 Demo。
一条最基础的战斗循环,拆开来看无非这几件事:
- 玩家输入:移动、瞄准、攻击键。
- 角色行为:根据输入改变动画、朝向、攻击状态。
- 攻击触发:生成子弹/近战判定,管理攻击间隔和输入缓冲。
- 碰撞检测:子弹与敌人、玩家与敌人的碰撞。
- 伤害处理:扣血、无敌帧、击退、受伤闪白。
- 敌人行为:巡逻、索敌、受伤、死亡。
- 表现层反馈:粒子、伤害数字、屏幕震动、音效。
- 资源回收:子弹、敌人、特效的复用与销毁,保证内存稳定。
这一篇我重点处理 1、3、4、5、8,也就是玩家侧和子弹侧的内容。敌人只用一个人工智障级别的“站桩木桩”,纯粹为了验证碰撞和伤害流程,真正的敌人 AI 留给后面的系列文章。
这么设计的理由是:战斗手感的核心瓶颈往往不在敌人智商,而在你输入到反馈这一小段极短链路里。输入延迟、子弹生成时机、碰撞形状不正确、对象池没做好,任何一环出问题都会让游戏“看起来哪里不对”。先把玩家侧打磨好,后面加敌人和 Boss 才会轻松。
1.2 战斗场景的节点树怎么挂
场景树搭得合理,代码逻辑才会清晰。我推荐一个很通用的结构:
code复制Main(Node2D)
├── Player(CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Muzzle(Marker2D)
├── BulletManager(Node2D)
├── EnemyContainer(Node2D)
├── Camera2D
└── UI(CanvasLayer)
关键的策略点在于:子弹不要挂在 Player 下面,敌人也不要直接塞进 Main 根节点。为什么?因为子弹和敌人数量多、生命周期短,如果用 Player 作父节点,那么当 Player 被移除、切换场景或死亡时,所有子节点都会被连带清理;搞不好你人物消失,子弹也全没了。把子弹统一交给 BulletManager 管理,后续做对象池、批量回收、暂停子弹运动都极其方便。敌人放 EnemyContainer 同理,方便遍历所有敌人做全屏攻击或者统一刷新。
Muzzle(枪口)是一个 Marker2D,挂在玩家身上,标记子弹生成的出生点,这样子弹始终从枪口飞出,而不是从玩家中心。这个细节很多新手会忽略,直接拿 global_position 发射,视觉效果差很多。
1.3 第一版“按下按键就开火”的最小代码
先跑通最原始的功能,再一步步优化。创建一个子弹场景 Bullet.tscn,根节点用 Area2D,挂一个 Sprite2D(随便用一个方形或圆形图标都行)和 CollisionShape2D。子弹脚本我开篇先只做最基础的位移:
gdscript复制extends Area2D
var direction := Vector2.RIGHT
var speed := 600.0
func _physics_process(delta: float) -> void:
global_position += direction * speed * delta
然后玩家脚本里,监听开火键,实例化子弹并设置方向和位置:
gdscript复制extends CharacterBody2D
@export var bullet_scene: PackedScene
@onready var muzzle: Marker2D = $Muzzle
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("attack"):
fire()
func fire() -> void:
var bullet := bullet_scene.instantiate() as Area2D
bullet.global_position = muzzle.global_position
bullet.direction = (get_global_mouse_position() - global_position).normalized()
get_tree().current_scene.add_child(bullet)
这里直接 add_child 到当前场景,跑通后我们再改成 BulletManager + 对象池。_unhandled_input 比 _input 更好,因为它只在 UI 没有消费这个事件时才触发,避免你以后做了暂停菜单后,UI 盖在上面时玩家还能开枪。
跑通这一步后,你会在屏幕上看到子弹飞出去,但命中敌人什么都不会发生,还很卡——尤其是连续点击时。别急,接下来就是性能问题的主场。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 输入手感:斜向移动修正和攻击方向才是老玩家在意的事
2.1 斜向加速问题,90% 的 2D Demo 都有
如果你之前用的是最简单的方式写移动:
gdscript复制velocity = Vector2(Input.get_axis("move_left", "move_right"),
Input.get_axis("move_up", "move_down"))
你会发现一个很明显的问题:同时按右和下时,角色移动速度比单按右时快了大约 41%。因为向量的模不再是 1 而是约 1.414。物理直觉上,你希望无论朝哪个方向跑,速度恒定。
修复方式很简单,归一化向量就行:
gdscript复制var input_dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = input_dir * move_speed
Input.get_vector 是 Godot 4 提供的方法,它已经内置了对斜向输入归一化处理。更底层我用过 Vector2.NORMALIZE 或 limit_length,效果一样,但能省一点就别自己写了。
不过要注意,get_vector 处理的是模拟轴(摇杆)和数字按键混合输入,如果你做了自定义动作,确保四个方向动作的 deadzone 设置合理,默认 0.5 就行,手感和 Godot 的摇杆映射兼容性都不错。
2.2 鼠标瞄准与键盘八方向,哪个更适合你的项目
这一条没有绝对答案,取决于你的游戏类型。
- 射击类、Roguelike 类:几乎无脑选鼠标瞄准。玩家用鼠标定位比键盘按键精确得多,而且天然支持 360° 任意方向。实现就是
get_global_mouse_position()减去玩家坐标,然后normalized()。 - 平台跳跃、动作类、手柄主打的游戏:建议键盘八方向。因为鼠标瞄准会迫使玩家频繁移动鼠标,如果动作密度高(需要同时跳跃、冲刺、放技能),鼠标很容易“飞”出目标范围。
实际项目里还可以做混合处理:如果最近有鼠标移动,就用鼠标方向;如果检测到手柄摇杆,就用摇杆方向。核心代码是记录设备输入来源并切换瞄准向量。
两种方向的发射代码都要集中写到一个函数里,比如 get_aim_direction(),这样以后做自动瞄准、磁力瞄准、辅助吸附时只改一个函数即可。不要在每个技能函数里到处散落方向计算,后面维护你会感激现在的自己。
2.3 输入缓冲:让按攻击键“想在前面”
动作游戏玩家对手感最敏感的一个点就是输入是否有“缓存”。设想一个场景:你还在上一个攻击动作的后摇期,此时按下攻击键,理论上应该等动作结束立刻接下一次攻击,而不是吞掉这次输入。很多新手实现攻击时会用 if cooldown <= 0: fire(),结果后摇期按了键没反应,玩家就会觉得“这游戏不跟手”。
解决方案是输入缓冲。用一个变量记录按下攻击键的时间或者把输入标记记到 _unhandled_input,下一帧检测攻击状态可变时消费它:
gdscript复制var _attack_buffer := 0.0
const ATTACK_BUFFER_TIME := 0.15
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("attack"):
_attack_buffer = ATTACK_BUFFER_TIME
func _physics_process(delta: float) -> void:
_attack_buffer = max(_attack_buffer - delta, 0.0)
if can_attack() and _attack_buffer > 0.0:
fire()
_attack_buffer = 0.0
这个 ATTACK_BUFFER_TIME 是经验值,0.1~0.15 秒比较合适。太短等于没缓存,太长会让玩家觉得“我明明没按为什么他动了”,具体数值需要根据你游戏实际手感微调。
别小看这 100 多毫秒,它往往是“Demo 感”和“商业手感”之间的一道关键分水岭。类似的还有跳跃缓冲、受击硬直中的输入保护,原理一样,改个变量就能复用到所有技能上。
3. 攻击判定与碰撞层:用 Area2D 做子弹,别用 CharacterBody2D
3.1 子弹选对碰撞节点类型,后面省一半心
Godot 中做子弹/攻击判定组一共有三种常用节点:Area2D、CharacterBody2D、RigidBody2D。很多新手会把子弹做成 CharacterBody2D,然后调用 move_and_slide(),这么做倒不是不行,但你会发现完全没有必要。
CharacterBody2D是为“受物理世界阻挡的角色”设计的,比如玩家、敌人,需要处理墙壁碰撞、斜坡、移动平台等。RigidBody2D是物理引擎驱动的刚体,适合弹跳、掉落物、被外力推飞的物体,自己做子弹还要担心物理材质参数干扰。Area2D是区域感应器,它自身不会阻挡任何物体,只负责检测“谁进入了我的区域”。这正是子弹需要的:我不在乎子弹推动别人,我只需要碰撞后触发伤害。
除非你要做“可以推开敌人”的巨型炮弹,否则子弹一律建议 Area2D。它的性能和可控性都比物理体好太多。子弹的运动轨迹完全由代码控制,想怎么拐弯、加速、追踪都由你说了算,物理引擎不会中途给你来个阻碍或者反弹。
3.2 碰撞形状:矩形、圆形、还是扫掠检测
碰撞形状的选择直接决定打击的“信任感”。玩家会觉得打中了,但碰撞体没匹配上,那是巨大的体验缺陷。
- 子弹:大多数情况用
CircleShape2D就够了,半径贴合子弹贴图。圆形碰撞方向无关,旋转角度 0 的时候撞击判断依然精确,调试也直观。 - 近战挥砍:用
RectangleShape2D放在玩家身前一个偏移位置。挥砍的判定框一般是短矩形(长 x 宽),比用圆更符合兵器横扫的感觉。 - 高速子弹(超过 1000px/s):Physics 默认在固定 tick 内“瞬移”,如果子弹一帧移动距离超过了目标体积,会出现经典穿模问题。有两种解法:一是开启
Area2D的“RayCast”连续检测(Continuous CD),二是手动做上一帧到这一帧的线段扫掠,比如PhysicsPointQueryParameters2D加intersect_ray。
实际项目里,强烈建议对高速子弹开启 PhysicsMaterial 的连续碰撞检测,或者用子弹速度上限 + 碰撞体半径补偿解决。我曾见过一套弹幕游戏里子弹速度 2400,结果子弹从 16x16 的敌人身体里直接穿过去,玩家疯狂反馈“明明瞄到了”,排查半天才发现是碰撞穿透。
3.3 碰撞层与 mask 设置:一劳永逸的隔离方案
Godot 4 的物理层设置在你选中节点后,属性面板里有个 “Layer”。编辑器右下角“项目设置 > 图层命名 > 2D Physics”里可以给 1~32 层起名字。我的习惯是给一个小型 2D 游戏只用前 5 层:
| 层编号 | 层名 | 代表的节点 |
|---|---|---|
| 1 | World | 地图、墙体 |
| 2 | Player | 玩家身体 |
| 3 | PlayerBullet | 玩家子弹 |
| 4 | Enemy | 敌人 |
| 5 | EnemyBullet | 敌方子弹 |
然后 CollisionShape2D 的 Layer(自己属于哪层)和 Mask(检测哪些层)按需设置。
以玩家子弹为例:Layer 设置为 3,Mask 配置为 4(只检测敌人)。这样子弹不会打中玩家自己、不会打中其他子弹、不会卡在墙体里。配合碰撞层,直接免疫掉一堆逻辑 bug。比如你不想让敌人子弹互相碰撞,只要双方 Layer/Mask 不同位即可。
设置表可以这样:
- 物体:Layer = 1(World),Mask = 1(自己作为物理阻挡体) 或 Mask = 2|4(可选,让敌人不能穿过)
- 玩家碰撞体:Layer = 2,Mask = 1|4
- 玩家子弹:Layer = 3,Mask = 4
- 敌人碰撞体:Layer = 4,Mask = 1|2|5
- 敌人子弹:Layer = 5,Mask = 2
做完这步,你的战斗场景就具备了最基础的伤害检测隔离机制,后续所有项目都能沿用这套分层方案。
4. 弹幕系统的性能核心:对象池与子弹生命周期管理
4.1 为什么直接实例化会撑不住一场像样的战斗
如果你在第二版实现了子弹可以命中敌人,然后开开心心给敌人加了一堆子弹,马上会碰到一个问题:子弹一多,帧数肉眼可见地跌。说到底,instantiate() 和 queue_free() 都是开销不低的操作。
每个 instantiate() 要重新加载场景资源、初始化节点树、触发所有 _ready(),queue_free() 又要等待帧末回收、断开信号、释放引用。连续几十次创建销毁,高频 GC 压力会让游戏卡到掉帧,尤其子弹这种生命周期只有一两秒的对象,堪称性能杀手。
解决思路是所有实时战斗项目都会用到的:对象池(Object Pool)。核心思想是——预先创建一批子弹节点,隐藏起来,需要发射时就取一个出来用,回收时不是销毁而是隐藏回池,下次再用。
4.2 手写一个最简 BulletPool
基于 BulletManager 节点,我写一个最简单的池子。你也可以引入第三方节点库,但基本功还是手写一遍更清楚。
gdscript复制extends Node2D
class_name BulletManager
@export var bullet_scene: PackedScene
@export var pool_size := 100
var _pool: Array[Area2D] = []
func _ready() -> void:
for i in pool_size:
var bullet := bullet_scene.instantiate() as Area2D
bullet.visible = false
bullet.set_physics_process(false)
bullet.manager = self
add_child(bullet)
_pool.append(bullet)
func get_bullet() -> Area2D:
for b in _pool:
if not b.visible:
return b
# 池子不够用时可以再临时创建,但这就是一种“超载”信号
var bullet := bullet_scene.instantiate() as Area2D
bullet.manager = self
add_child(bullet)
_pool.append(bullet)
return bullet
func release_bullet(bullet: Area2D) -> void:
bullet.visible = false
bullet.set_physics_process(false)
bullet.get_node("CollisionShape2D").set_deferred("disabled", true)
关键的优化是:取子弹时,先复用 _pool 里不可见的对象;回收时不要销毁,直接隐藏并禁用碰撞。子弹脚本里需要把 free() 改成调用 manager.release_bullet(self):
gdscript复制func _on_lifetime_timeout() -> void:
manager.release_bullet(self)
在弹幕游戏中,池子大小建议估算峰值并发子弹数。你测试时发现池不够,它会在运行时扩容,但那会临时实例化,尽量通过反复压测确定一个峰值上限。100 发通常足够单人演示用。
4.3 子弹生命周期:三种回收策略缺一不可
子弹不回收,池子再大也会被耗尽。我总结三种回收触发条件,建议全部实现,才能保证池子稳定工作:
- 命中敌人:碰撞信号
area_entered里调用回收。 - 飞出屏幕:用
VisibleOnScreenNotifier2D节点监听screen_exited信号,在子弹出屏时立刻回收。 - 超时兜底:每个子弹挂一个
Timer或脚本内倒计时,如 3 秒无论如何都回收,防止子弹卡在屏幕死角或追踪类技能永久存活。
另外,如果你的子弹会碰到墙体或障碍物,还需要在 area_entered 里判断撞上的区域是否是地图碰撞区域,若是则也回收——尤其是穿透型子弹需要手动处理这种边界情况。
4.4 对象池常见误区
- 误区一:池子里的子弹一直有物理检测。回收时务必
set_physics_process(false)+ 禁用 CollisionShape2D,否则虽然不可见,但依然参与物理检测,性能照样崩。 - 误区二:用
queue_free()而不是隐藏对象。那就失去了池子的意义。 - 误区三:预创建时所有子弹都挂在同一个节点下导致层级混乱。可以让池在
_ready()时按批次创建,并把visible=false,调试时通过场景树查看哪些子弹被复用。 - 误区四:子弹脚本里
_physics_process里每帧检查global_position屏幕边界。这个逻辑开销远大于只由VisibleOnScreenNotifier2D发信号,后者只在实际渲染出去时通知你。
如果你的目标平台是 Web 导出,浏览器里引擎的 GC 压力更高,对象池更是必需品。
5. 受击反馈:伤害数字、闪白、击退与命中停顿
5.1 伤害数字:用 Tween 做一个不卡顿的弹出动画
打中敌人不显示数字,玩家很难确认自己有没有造成伤害。实战中我习惯做一个独立的 DamageNumber 场景,根节点为 Label,同样挂在 BulletManager 或专门的 EffectManager 下,使用对象池复用。
gdscript复制extends Label
func show_damage(value: int, pos: Vector2) -> void:
text = str(value)
global_position = pos
modulate.a = 1.0
var tween := create_tween()
tween.set_parallel()
tween.tween_property(self, "position:y", position.y - 30, 0.5)
tween.tween_property(self, "modulate:a", 0.0, 0.5)
tween.chain().tween_callback(_recycle)
func _recycle() -> void:
get_parent().release_damage_number(self)
这里注意,create_tween() 会自动跟随 Label 节点,不用担心生命周期问题。反复创建 / 释放 tween 也是小开销,但配合对象池足够满足大多数 2D 游戏的反馈需求。
5.2 受击闪白、击退与屏幕震动:三个小成本特效
这部分属于“表现层”的核心。哪怕是同一个 Demo,有没有这些反馈,玩起来完全两个游戏。
受击闪白:给敌人的 Sprite2D 材质一个 Shader,受击时把 modulate 或自定义 shader 参数从正常颜色闪到白色再恢复。如果你不想写 Shader,简单做法是 modulate = Color(1, 0.3, 0.3, 1),用 Tween 在 0.1 秒内恢复。低成本且效果尚可。更专业的做法是给 Sprite 的 material 设置 shader_type canvas_item,然后控制 mix 值:
gdscript复制shader_type canvas_item;
uniform float flash_amount : hint_range(0.0, 1.0) = 0.0;
void fragment() {
vec4 color = texture(TEXTURE, UV);
COLOR = mix(color, vec4(1.0), flash_amount);
}
然后在受击时 material.set_shader_parameter("flash_amount", 1.0),用 Tween 降至 0。
击退:在敌人受击函数里,给它的 velocity 加一个方向向量,并用 move_and_slide() 结合短暂的无敌帧。注意击退力度不要过大,否则会破坏游戏节奏。一般 80~200 之间,具体取决于碰撞体大小。
屏幕震动:实现方式很多,最常见是修改 Camera2D 的 offset,在受击瞬间给一个随机偏移量,然后用 Tween 回到 Vector2.ZERO。
gdscript复制func shake(intensity := 5.0, duration := 0.2) -> void:
var tween := create_tween()
tween.tween_method(_apply_shake, intensity, 0.0, duration)
func _apply_shake(amount: float) -> void:
camera.offset = Vector2(randf_range(-amount, amount),
randf_range(-amount, amount))
注意震动幅度要随游戏节奏合理衰减,不建议一打架就满屏乱抖,玩家很快会头晕。
5.3 命中停顿 Hit Stop
命中停顿(Hit Stop)是动作游戏打击感里最核心的玄学之一。原理很简单:命中敌人那一瞬间,把整个游戏时间暂停十几毫秒,再用回放放缓或恢复,玩家会本能地感受到“这一击很有力”。
Godot 里实现这是极简单的事:
gdscript复制func _hit_stop(duration := 0.08) -> void:
Engine.time_scale = 0.02
await get_tree().create_timer(duration * Engine.time_scale).timeout
Engine.time_scale = 1.0
await 与 get_tree().create_timer() 搭配可以构造一个异步延时,不必单独写信号。但有一个大坑:如果 Engine.time_scale 被设置成 0.02,而你在场景里用了 process_mode = PROCESS_MODE_ALWAYS 的 UI 或者音频管理器,它们不会受 time_scale 影响,你需要对它们单独处理。此外,create_timer 的默认是受 time_scale 影响的,所以暂停时计时也会变慢,上面 duration * Engine.time_scale 是为了在真实时间中保持那 0.08 秒停顿,实际试用时你会明白这个技巧。
命中停顿一定要控制频率,最好只在暴击、击杀、重击时触发,一场普通攻击也每帧停顿会严重影响动作流畅度。
6. 调试与实测经验:物理帧率、信号重复连接和碰撞回调优化
6.1 用 Godot 的性能监控器看战斗开销
Godot 4 编辑器自带性能监控器(Debug > Monitors),这个工具在我调战斗时几乎不离手。你重点看这几个指标:
- Physics Process(物理帧耗时):如果这里偏高,说明物理检测节点太多或碰撞层设置不合理。
- Process(逻辑帧耗时):对象池复用是否生效、AI 和移动代码是否过于复杂。
- Render(渲染耗时):贴图数量、粒子特效、Camera 重叠等因素。
- Objects(场景节点数量):如果这个值在不断攀升,说明你的对象池没有正确工作,或是某些子弹没有被回收。
我调一个 150 颗子弹同屏的弹幕场景时,就是用监控器确认了“对象池开启后 Physics Process 从 18ms 降到 2ms”,这才敢放心做高密度战斗内容。
6.2 一个经典坑:信号重复连接导致一个子弹触发两次伤害
这是我在初学 Godot 时踩得最深的坑之一。你在编辑器中手动连接了 area_entered 信号,又在代码里 connect 了一次,同一个信号被触发了两次,敌人血量瞬间减两格,且子弹也回收到池子后再次触发回调。
排查链路是这样的:
- 先打印
area_entered收到的对象,确认是同一个子弹命中了两次还是两个子弹。 - 检查场景树里的连接情况:在编辑器里选中子弹节点,检查“Node > 信号”列表里是否既有编辑器连接又有代码连接。
- 修复:统一切到代码连接,或者在
_ready里disconnect旧信号再重新连接。
一个更稳妥的做法是把信号处理逻辑放在 _on_area_entered 回调里,用 set_deferred() 延迟禁用碰撞形状,在下一帧才真正回收,避免在同一物理帧内反复触发。
gdscript复制func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("enemy"):
area.take_damage(10)
$CollisionShape2D.set_deferred("disabled", true)
manager.release_bullet(self)
注意 set_deferred 非常关键,如果你直接在回调里禁用物理形状,可能会导致正在处理的物理查询报错或漏检测。
6.3 碰撞回调里不要做耗时操作
新手最爱在 area_entered 里直接创建特效、播放音效、实例化伤害数字。这在少量命中时没问题,一旦同屏几十次命中,每帧都在动态实例化和销毁节点,性能直接崩掉。
正确的姿势是:
- 碰撞回调里只做核心逻辑:扣血、标记回收。
- 表现层效果(粒子、音效、伤害数字)交给统一的表现管理器,通过对象池取可用对象。
- 如果必须立刻反馈,把耗时操作丢进
call_deferred或者延迟到_process尾部统一处理。
举个小例子,我早期在子弹命中时直接在回调里 AudioStreamPlayer 播放音效,同时实例化一个爆炸粒子。当敌人被一串子弹打中时(比如霰弹枪),一次回调 20 发子弹,瞬间创建 20 个粒子节点和 20 个音效播放器,没有任何缓冲,直接演变成迷你卡顿现场。后来全部改成对象池 + 延迟调用,同屏 200 发霰弹也不卡。
6.4 关于物理帧率的另一个坑:Physics Ticks 与子弹运动平滑度
用 _physics_process 移动子弹是标准做法,但它默认固定 60Hz,如果游戏渲染帧率超过 60,你可能会觉得子弹运动偶有卡顿。在部分高刷新率屏幕上尤其明显。解决办法是将子弹移动放到 _process 并乘以 delta,但要注意物理检测仍然在物理帧里,要保证它们的一致性。
一个折中的做法是保持 _physics_process 移动,但在“视觉渲染”路径上使用插值。Godot 4 的部分内置节点和 RenderingServer 已经做了空间插值,但如果你在渲染帧率远高于 60Hz 的机器上做测试,建议直接用 _process 来控制子弹位移,并将碰撞检测用 PhysicsPointQueryParameters2D 手动替代。不过这是高级调优内容,普通动作游戏用 _physics_process 完全够,不必过于复杂。
我个人在实际项目里,子弹对象池和输入缓冲这两样东西是从头到尾都离不开的底子。你后面无论做弹幕游戏、Roguelike 射击还是横版动作,这篇里的战斗循环都可以直接复用。继续做的话,下一个值得花时间的点是把敌人 AI 和子弹样式做进数据表里,方便策划批量调节,而不是在代码里一个个硬编码。等你把这两样串起来,一个 2D 动作游戏的核心玩法框架就真正立住了。
