如果你正在跟着系列手把手做一款Godot 2D游戏,到了第17篇这个阶段,战斗逻辑、敌人AI、基本UI应该都已经跑通了。但很多同学卡在一个很微妙的地方:明明该做的都做了,真打起来却总觉得"手感不对",打击感稀烂,像在打一个会飘的纸片。问题基本都出在反馈层——血条、伤害数字、受击闪白、震屏、音效,没有把这些反馈串成一个系统。
这一篇我就集中把战斗反馈这套东西讲透。从血条该挂在哪、伤害飘字怎么做,到Tween动画、相机震动、Shader闪白,全部按实际项目里能直接用的标准来写。适合已经完成前16篇基础教程、手头有一个可玩战斗场景的开发者,也适合那些想把手感做扎实但不知道从哪下手的Godot 2D玩家。
1. 第17篇的选题逻辑:为什么这个阶段才碰反馈系统
1.1 战斗反馈到底解决什么问题
先聊点实际的。你写完攻击判定、碰撞检测、死亡逻辑后,玩家按一个键,怪物扣血,血量归零就消失。逻辑上完全没问题,但人眼接收到的信息非常单薄:画面只发生了"怪物消失"这一个事件,中间没有过渡、没有信息呈现。玩家不知道这刀砍了多疼,不知道打中哪个部位有效,甚至不知道怪到底还剩多少血。这种状态下,动作游戏的"爽感"是不存在的。
反馈系统本质上是在干一件事:把每一次战斗事件,转换成玩家能快速感知的视觉、听觉信号。血条拖动表示伤害量,飘字表示伤害数值,闪白表示“我确实被打中了”,震屏表示“这一下不轻”,音效则负责把这一切钉在玩家记忆里。这些信号不需要多复杂,但必须及时、明确、不混乱。这就是为什么第17篇要专门讲这个主题——功能已经跑通,接下来要让游戏"像游戏"。
1.2 本篇会用到哪些Godot核心能力
这一篇不是从头教新功能,而是把Godot里已有的几个模块组合起来用:Control节点体系做血条和飘字、Tween做动画缓动、信号做数据流解耦、Shader做受击闪白、Camera2D做震屏。最后还会涉及一个简单的对象池设计,因为飘字和音效在频繁触发时,动态创建销毁会带来卡顿,这是很多项目做到后期才意识到的问题。
我会用Godot 4.x的语法,GDScript为主。如果你还在用3.x,原理完全一样,只是个别API名称不同(比如create_tween在3.x里对应create_tween的替代方案是Tween节点),但思路可以照搬。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. HUD架构选型:血条和飘字不一定要挂在CanvasLayer里
2.1 为什么直接挂在敌人节点下是个坑
一开始我踩过一个典型的坑:为了让血条跟随敌人,我直接把TextureProgressBar拖进敌人场景,放在AnimatedSprite2D下面。结果敌人的左右转向一开,血条直接镜像翻转,原本从左往右扣的血量变成从右往左,名字标签也全部反着。这个问题的根源在于:在Godot 2D里,Control节点虽然能放在Node2D下,但它依然会受到父节点的scale、rotation影响。而2D角色做朝向翻转时,最常用的方式就是给Sprite设置flip_h,或者直接把scale.x设为负数。
第二个坑是摄像机缩放。如果游戏有zoom效果,挂载在敌人节点下的血条会跟着世界一起缩放。玩家视角里血条忽大忽小,很出戏。第三个坑是遮挡:血条作为世界内节点,可能被前景遮挡物盖住,某些像素游戏里血条会穿墙露出来,非常难看。
所以正确思路是:不要把“世界坐标里的信息”和“屏幕空间里的UI”混为一谈。血条、飘字这类战斗反馈,要么做进屏幕空间(用CanvasLayer),要么做进世界空间但要处理好翻转和缩放问题。对新手来说,我更推荐先理解清楚再选。
2.2 我建议的节点结构:世界内反馈+朝向修正
既然CanvasLayer方案需要做坐标转换,对新手理解成本高,那么这篇教程我先给出一套更直接的世界内反馈方案,适合绝大多数俯视角、横版2D游戏。把敌人的HUD做成一个独立场景,根节点是Control,它作为敌人场景的子节点。具体结构如下:
text复制Enemy (CharacterBody2D)
├─ AnimatedSprite2D
├─ Health (Node)
├─ EnemyHud (Control)
│ ├─ HudRoot (Control)
│ │ ├─ BloodBarBack (TextureProgressBar)
│ │ ├─ BloodBarFront (TextureProgressBar)
│ │ ├─ NameLabel (Label)
│ │ └─ DamageNumberSpawner (Node2D)
└─ Hitbox (Area2D)
注意HudRoot不设置任何anchors,直接用绝对像素位置控制,放在敌人头顶上方。敌人翻转时,HudRoot会跟着翻转,所以需要在脚本里做一个修正。这个修正逻辑看起来简单,但非常关键,我在后面“实测踩坑”里会展开。
2.3 什么时候才需要CanvasLayer
如果你的游戏是强UI型、画面铺满大量信息,或者要做全局伤害数字(比如玩家掉血提示也走同一套飘字),那还是建议加一个CanvasLayer,把所有反馈UI放在里面。这时飘字的坐标就不是敌人身上的局部坐标,而需要把敌人global_position转换成屏幕坐标:
gdscript复制var screen_pos := get_viewport().get_camera_2d().get_screen_center_position() + (enemy.global_position - get_viewport().get_camera_2d().get_screen_center_position())
# 这段是示意,实际多用于视口坐标换算,如果你的相机没有旋转缩放,可以直接用 canvas_transform
说句实在话,这一步对新手特别劝退。所以我建议大多数2D项目先做“世界内方案”,跑通整个反馈闭环,然后再决定是否升级成CanvasLayer。手感是核心,不要一开始就耗在坐标换算上。
3. 血条实现:从单条血到有手感的双段迟滞条
3.1 基础配置:TextureProgressBar比ProgressBar强在哪
Godot提供两种最常用的血条控件:普通ProgressBar和TextureProgressBar。单论功能,ProgressBar够用,能设置min_value、max_value、value,还能显示百分比文字。但实际游戏里血条要常驻在角色头顶,通常需要贴合游戏美术风格,用一张窄长的图片做背景,另一张做填充。这时TextureProgressBar就舒服多了。
我一般这样配置:min_value = 0.0,max_value = 1.0,先把数值归一化,避免在代码里到处传最大血量。设置fill_mode = 0表示从左往右填充。under_texture放血条背景图,progress_texture放填充图,over_texture可放高光边框。关闭show_percentage,血条顶部不需要数字,数字交给飘字系统。
在Health组件的信号回调里这样更新:
gdscript复制func _on_health_changed(current: float, maximum: float) -> void:
blood_bar_front.max_value = maximum
blood_bar_front.value = current
如果current是浮点数,记得把step设成0.001,否则每次扣血显示出来的进度会一格格跳,看起来非常钝。
3.2 迟滞血条:为什么掉血要分两段
玩过《空洞骑士》或者《暗黑地牢》这类游戏的同学应该见过:受伤瞬间,红色血条立刻掉到某个位置,但紧接着还有一条白色或者黄色的残影血条,缓慢地滑到同样的位置。这就是“迟滞血条”,作用是让玩家在高速战斗里也能看清刚才掉了多少血,极大提升伤害反馈的直观性。
实现思路非常朴素:用两条TextureProgressBar叠在一起。前血条(靠上)收到信号后立刻更新;后血条(靠下)收到信号后不立刻更新,而是先等0.3秒,再用Tween平滑移动到当前值。代码如下:
gdscript复制func _on_health_changed(current: float, maximum: float) -> void:
blood_bar_front.max_value = maximum
blood_bar_front.value = current
if blood_bar_back_tween and blood_bar_back_tween.is_valid():
blood_bar_back_tween.kill()
blood_bar_back_tween = create_tween()
blood_bar_back_tween.tween_interval(0.3)
blood_bar_back_tween.tween_property(blood_bar_back, "value", current, 0.4)\
.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUINT)
这段代码有个细节:每次扣血都要先kill上一次的Tween,否则连续被攻击时后血条会排队等半天才动,表现起来像卡住了一样。这个坑我在第一次实现时踩过,当时怪挨三刀,后血条隔了好几秒才追上来,观感很怪。
3.3 血条显隐策略:小怪不常显,精英Boss常显
不是每个怪都适合常年顶着血条。我的建议是:普通小怪默认隐藏血条,受伤时显示2秒再淡出;精英怪和Boss进入战斗后常显。原因很简单:满地都是血条会遮挡场景信息,玩家也会审美疲劳。
显隐用modulate.a控制最方便,CanvasItem都有这个属性。给HudRoot挂一个简单的show_timer,扣血时重置计时器,在_process里做倒计时和淡出:
gdscript复制func _process(delta: float) -> void:
if not always_visible:
if visible_timer > 0.0:
visible_timer -= delta
hud_root.modulate.a = 1.0
else:
hud_root.modulate.a = max(hud_root.modulate.a - delta * 3.0, 0.0)
顺便提一句,如果你的敌人有“睡眠”或者“隐身”状态,不想让玩家看见血条,记得把这个逻辑也接进去。否则血条会把敌人真实的存活状态暴露出来,等于做了个反外挂提示。
4. 伤害飘字核心:Label只是起点,重点是Tween和对象池
4.1 新手最容易犯的错:用一个Label反复set_text
我看到过很多半成品项目,伤害飘字是一个Label挂在角色头顶,每次攻击就set_text一下,然后不管了。结果是:快速攻击时文字高频刷新,玩家根本看不清数字;两个伤害同时命中时,后一个把前一个顶掉了;暴击和普通伤害完全长一个样,信息量约等于零。
正确做法是做一个独立的DamageNumber场景,每个飘字都是自己的实例,然后通过对象池复用。先说一下飘字场景的结构:
text复制DamageNumber (Label)
就一个Label就够了,所有样式通过LabelSettings设置。它不需要继承Node2D,因为这里用的是世界内方案,直接放在敌人节点下的DamageNumberSpawner里。
4.2 飘字脚本怎么设计
飘字的核心是两个东西:setup()方法接收伤害数据,然后启动Tween动画;动画播放完毕后回收自己。我给一个完整的脚本模板,几乎可以直接复制到项目里用:
gdscript复制extends Label
var pool: Array[Node] = []
func setup(value: int, is_crit: bool, offset: Vector2 = Vector2.ZERO) -> void:
text = str(value)
if is_crit:
add_theme_color_override("font_color", Color(1.0, 0.62, 0.0))
add_theme_font_size_override("font_size", 28)
else:
add_theme_color_override("font_color", Color(1.0, 1.0, 0.4))
add_theme_font_size_override("font_size", 18)
position = offset + Vector2(randf_range(-12, 12), -24)
modulate.a = 1.0
scale = Vector2(0.8, 0.8)
_play_tween(is_crit)
func _play_tween(is_crit: bool) -> void:
var tween := create_tween().set_parallel(true)
tween.tween_property(self, "position:y", position.y - 40.0, 0.6)\
.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD)
tween.tween_property(self, "modulate:a", 0.0, 0.55)\
.set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_QUAD)
if is_crit:
tween.tween_property(self, "scale", Vector2(1.15, 1.15), 0.12)\
.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
else:
tween.tween_property(self, "scale", Vector2.ONE, 0.15)\
.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
await tween.finished
if pool.is_empty():
queue_free()
else:
reparent(pool[0])
queue_free()
注意暴击的Tween用了一个TRANS_BACK,效果是数字会先超过目标尺寸再弹回来,视觉上就有一个“弹”的动感。普通伤害用TRANS_CUBIC就好,不要所有数字都弹,看久了非常浮夸。
4.3 为什么飘字必须要用对象池
如果每次伤害都instantiate()一个飘字,飘完就queue_free(),项目刚跑起来没问题。但到了后期,毒圈每秒跳几十次伤害、技能AOE同时打中十几个敌人时,飘字一多,游戏画面会肉眼可见地掉帧。原因不是Label本身有多重,而是高频实例化、释放的GC开销积少成多。
对象池的思路很简单:预先实例化一批飘字,隐藏起来。需要显示时,从池里取一个、设好数据、显示、播Tween,播完后不删除,而是收回池子里等待下次复用。下面是池子的最简实现,挂在EnemyHud的DamageNumberSpawner节点上:
gdscript复制extends Node2D
var _pool: Array[DamageNumber] = []
var _prefab: PackedScene = preload("res://scenes/damage_number.tscn")
func spawn(value: int, is_crit: bool, global_pos: Vector2) -> void:
var num: DamageNumber
if _pool.size() > 0:
num = _pool.pop_back()
num.get_parent().remove_child(num)
add_child(num)
else:
num = _prefab.instantiate()
add_child(num)
num.global_position = global_pos
num.setup(value, is_crit)
num.released.connect(_recycle.bind(num))
func _recycle(num: DamageNumber) -> void:
num.visible = false
num.get_parent().remove_child(num)
_pool.append(num)
这段代码里最关键的是released信号和_recycle。飘字动画播完,发出信号,由池子接管,而不是自己queue_free。新手容易在“谁负责回收”这件事上搞混,我的原则是:飘字自己只管播动画,回收逻辑永远由池子决定。
4.4 暴击、治疗、免疫这些特殊飘字怎么区分
给setup()传入的不应该只有数值,最好是一个“伤害事件类型”。我一般用一个枚举:HIT_NORMAL、HIT_CRIT、HEAL、IMMUNE。飘字内部根据类型选择颜色、字号、上飘高度。治疗飘字是绿色,且带一个“+”前缀;免疫飘字是灰色,只显示“免疫”两个字,不上飘也不淡出,原地闪一下就行。
一个重要的架构边界:飘字系统只负责“显示”,它不负责计算伤害。伤害数值和是否暴击,应当由Health组件或伤害计算逻辑给出,飘字只是接收结果。否则以后加入格挡、吸收、护盾这类机制时,飘字代码会越改越乱。这个原则我在下面数据流部分会重点说。
5. 数据流设计:伤害从哪来,飘字怎么知道自己该出现
5.1 在Health组件里统一发信号,别到处手动调飘字
我见过不少项目,飘字是在攻击者的脚本里生成的:玩家攻击敌人,攻击判定代码里调用enemy.spawn_damage_number(15)。这在一开始很顺手,但一旦加入毒、火烧、陷阱、友军伤害,你会发现这些“伤害来源”代码全部都要加一行飘字调用,漏掉哪一个,反馈就不见了。正确的解法是让反馈成为被动订阅者。
具体做法是:敌人的Health节点只负责登记血量和改变血量,同时向外广播信号。信号内容只需要两个:
gdscript复制signal health_changed(current: float, previous: float, reason: String)
signal died
血量变化时,发出health_changed。EnemyHud和血条、音效、震屏、AI这些系统各自监听这个信号,按自己的职责做响应。比如Hud收到信号后,计算previous - current得到本次伤害值,再生成飘字。这样不管伤害来源是玩家攻击、毒圈、脚本扣血,反馈都不需要额外写一行。
5.2 飘字的坐标和层级
世界内方案的飘字,生成坐标很好取。在EnemyHud的DamageNumberSpawner里,spawn(value, is_crit, global_position)接收的是一个世界坐标。调用时会从敌人节点取头顶位置:
gdscript复制var number_pos := enemy.global_position + Vector2(0, -54)
damage_number_spawner.spawn(damage_value, is_crit, number_pos)
如果飘字直接显示在敌人头顶,可能被敌人自己的Sprite挡住。这时可以让生成器在z_index上比敌人高,或者给飘字场景的z_as_relative设为false并显式设置z_index = 50。这个方案最简,大家可根据项目实际情况调整层级。
这里要提一个和Tween相关的坑:如果飘字是敌人节点的子节点,敌人死亡时飘字也会一起被删。如果Tween还在播放,Godot可能会在节点释放后访问Tween时报错。解决方式有两种:一是飘字延迟生成,保证敌人死亡前已经完成;二是飘字不挂在敌人下,而是挂在当前场景的某个固定容器中,只初始化坐标。第二种更稳妥,但需要手动维护“飘字容器”和“正在活跃的飘字列表”。我建议学有余力的同学直接上第二种,这会让后续做全局飘字(比如玩家头顶回血提示)顺畅很多。
5.3 高频伤害的聚合显示
如果同一帧内敌人被多段伤害同时命中,飘字会叠成一个密密麻麻的数字堆。我的处理方式是:活跃飘字数组里,如果当前帧已经存在一个飘字,就把新伤害累加到旧飘字的数值上,并重置这个飘字的淡出计时器:
gdscript复制func spawn_aggregated(value: int, is_crit: bool, global_pos: Vector2) -> void:
for num in active_numbers:
if num.is_active and num.global_position.distance_to(global_pos) < 16.0:
num.add_value(value, is_crit)
return
spawn(value, is_crit, global_pos)
这样做还有一个好处:当敌人被毒持续伤害时,你看到的是一个不断跳动的“中毒5、中毒5、中毒5”合并成“中毒xN”,而不是一串滚动的数字墙。这个技巧对性能、观感都有帮助,强烈建议做进飘字系统。
6. 受击反馈的其他维度:闪白、震屏、音效别落下
6.1 受击闪白:modulate够了,但Shader更好
飘字和血条解决“信息”,闪白解决“打击瞬间”。最简单的是用Tween把Sprite的modulate从白色变成亮灰色再变回来。代码就几行:
gdscript复制var tween := create_tween()
tween.tween_property(sprite, "modulate", Color(1.2, 1.2, 1.2), 0.05)
tween.tween_property(sprite, "modulate", Color.WHITE, 0.15)
颜色值超过1.0的modulate在Godot里是允许的,效果就是更亮更“闪过”的白色。但用modulate有一个问题:它整体提亮整张贴图,包括暗部和高光,观感不是特别精细。所以更推荐用Shader方式,只把高光部分提亮。给Sprite的canvas_itemShader:
glsl复制shader_type canvas_item;
uniform float white_ratio : hint_range(0.0, 1.0) = 0.0;
void fragment() {
vec4 tex = texture(TEXTURE, UV);
vec3 white = vec3(1.0);
COLOR = vec4(mix(tex.rgb, white, white_ratio), tex.a);
}
脚本里控制white_ratio从0到1再回到0,受击瞬间调到0.8左右,0.1秒后衰减。比modulate干净很多,而且不影响敌人身上已有的Shader效果。如果敌人有自定义Shader,不能直接挂这个shader,就用modulate方案过渡。
6.2 相机震动:trauma衰减比每帧随机偏移科学
震屏是一个很廉价的提升打击感手段。很多新手直接每帧给Camera2D设置随机offset,结果画面像筛糠一样抖个不停,玩起来想吐。正确的震屏是用trauma值控制震度,受伤时累加,每帧指数衰减,然后用衰减后的值乘以随机偏移。这样震感来得猛、去得也快,符合人体对“撞击”的感知习惯。
给相机挂一个脚本:
gdscript复制extends Camera2D
var trauma := 0.0
var max_offset := Vector2(6.0, 4.0)
func add_trauma(amount: float) -> void:
trauma = min(trauma + amount, 1.0)
func _process(delta: float) -> void:
trauma = max(trauma - delta * 1.8, 0.0)
if trauma <= 0.0:
offset = Vector2.ZERO
return
var shake := trauma * trauma
offset = Vector2(
randf_range(-1.0, 1.0) * max_offset.x * shake,
randf_range(-1.0, 1.0) * max_offset.y * shake
)
注意trauma * trauma这个二次方计算:0.9的震度和0.5的震度不是简单线性关系,而是0.81和0.25,这样前面衰减快、后面趋静,视觉上就干净很多。被玩家攻击时add_trauma(0.3),被重击暴击时add_trauma(0.6),数值可以按手感调。
6.3 音效的并发问题
攻击命中音效如果直接挂在角色上,快速攻击时同一个AudioStreamPlayer会连续触发,前一秒还没播完,下一秒又开始新一轮。Godot默认情况下同一Node再次play()会从头播放,如果触发频率高,听起来就像“噗噗噗”的爆音,尤其打击音效如果带重低音更明显。
简单有效的做法是:给敌人体内的AudioStreamPlayer设置max_polyphony = 4,让同一个音效允许最多4个实例混响,超过4个再触发就丢弃。这样既不会爆音,也不会因为多开而产生过高性能开销。如果你的战斗节奏非常快,一个hitbox同时命中多个敌人,建议把音效做在攻击方而不是受击方,这样一次挥刀只播放一次音效,避免5个敌人同时发出5声受击音。这个细节对“手感统一”很有帮助。
7. 新手最容易翻车的五个实测坑
7.1 敌人翻转后血条镜像
之前提到的翻转问题,具体修法是:敌人的Sprite在flip_h翻转时,把HudRoot的scale.x强制对齐到正负,但不要用flip_h,因为Control节点没有flip_h属性。
gdscript复制func _process(_delta: float) -> void:
if enemy_sprite.flip_h:
hud_root.scale.x = -abs(hud_root.scale.x)
else:
hud_root.scale.x = abs(hud_root.scale.x)
这里的前提是hud_root原始scale是Vector2.ONE。如果血条设计时左右不对称,这个思路会暴露问题,所以做血条美术时建议尽量左右对称,或者接受镜像效果。另外注意不要加反向旋转修正,除非敌人有旋转动画,否则只需要翻转x。
7.2 Control节点吃鼠标点击
Control节点默认mouse_filter是STOP,意思是它会拦截鼠标事件。放在场景里的血条、飘字如果没改这个属性,可能会挡住玩家点击交互,尤其是敌人死亡后,如果飘字节点还没被回收,玩家点它身后区域就是点不中。表现就是敌人的尸体变“空气墙”。解决方式:批量设置所有纯展示型Control的mouse_filter为IGNORE:
gdscript复制hud_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
这个坑很隐蔽,容易在加入关卡交互后突然冒出来。
7.3 字体模糊与中文豆腐块
Godot 4默认字体不支持中文。如果伤害飘字除了数字还显示“暴击”“免疫”“中毒”,默认字体全会变成方块。解决方式:在LabelSettings里指定一个带中文的字体文件,例如下载一个开源的中文字体tff/otf,放到项目资源里填充LabelSettings.font。
另一个模糊问题是窗口拉伸。项目设置中把display/window/stretch/mode设为canvas_items,aspect设为expand,这样UI在窗口大小变化时依然保持清晰。像素风游戏还要把ProjectSettings>rendering/textures/canvas_textures/default_texture_filter设为Nearest,否则放大后字体和Sprite边缘会糊成一片。
7.4 Tween目标被释放
当飘字或后血条的Tween正在运行时,如果敌人死亡被queue_free,节点被释放,Tween还在尝试操作已释放的目标,控制台会飘红。最简单应对方案:在敌人生存期结束时主动杀掉所有相关Tween。我的做法是HudRoot脚本里保存所有Tween引用,在_exit_tree中统一kill:
gdscript复制var _tweens: Array[Tween] = []
func _exit_tree() -> void:
for t in _tweens:
if t and t.is_valid():
t.kill()
如果是飘字独立在场景容器里,不挂在敌人下,这个坑基本能避开。所以再说一遍:能独立就独立,别图省事挂到敌人节点下面。
7.5 对象池复用漏重置状态
对象池复用最烦人的问题就是状态残留。我的池子回收飘字时,visible = false、modulate.a = 1.0、scale = Vector2.ONE、text = ""、位置重置,这几个字段至少都要恢复到初始值。尤其别忘了scale,否则上一次暴击放大的飘字,下一次普通伤害出来还是放大状态,看起来就像所有伤害都是暴击。
8. 往前一步:把这套反馈做成项目级复用系统
如果只做到这里,这套系统已经能用了。但如果你想继续往下扩展,我建议把飘字生成从具体敌人里抽出来,做成一个Autoload单例。比如叫DamageNumberBus,暴露一个接口:
gdscript复制static func show_number(value: int, is_crit: bool, global_pos: Vector2, style: DamageStyle) -> void
内部维护一个容器节点和一个对象池,任何场景任何脚本都能直接调用。这样不仅敌人可以用,玩家回血、陷阱伤害、剧情演出时的强制扣血都能显示飘字,样式还可以根据DamageStyle资源配置不同字体、颜色、缓动曲线。做一个Resource类来存样式信息,比如:
gdscript复制class_name DamageStyle
extends Resource
@export var font: Font
@export var font_size := 18
@export var color := Color.WHITE
@export var outline_size := 2
@export var outline_color := Color.BLACK
@export var rise_height := 40.0
@export var duration := 0.6
用资源文件管理飘字样式,会比在代码里写死颜色、字号要灵活得多。以后策划要调,只需要改资源文件,不需要碰代码。
还有一个和AI联动的好习惯:受击反馈不仅给玩家看,还应当给敌人AI用。监听health_changed信号,血量首次降到50%以下时触发第二阶段;掉血时进入仇恨状态;连续被击退时取消当前攻击动作。把这些逻辑都放在信号回调里,反馈系统和AI状态机自然就解耦了,不会出现“怪物明明挨打了还是傻站着”的违和感。
我现在做战斗反馈系统,基本流程就是先接飘字、血条、闪白,这三个最便宜也最显眼;然后接震屏和音效,补足打击感;最后再做伤害聚合、池化、系统级的Bus抽象。顺序别反,先有反馈的“骨架”,再谈丰富和优化。等你在真实项目里把这一套跑通后再回头看,会发现很多所谓手感问题,根本原因都是反馈信息没有及时准确地到达玩家眼睛和耳朵里。
