GameTech/Godot 4.7 互动教程
目录示例游戏
第二部分 · 2D 基础 · 第 7 章

动画、补间与手感

同样的移动参数,加上角色动画、Tween 补间、挤压拉伸、粒子、屏幕震动和命中停顿之后,游戏的"手感"会完全不同。本章讲清楚每种效果的原理和写法,并且都可以单独开关对比。

上一章调的是"角色怎么动"。这一章不改任何移动参数,只加反馈:角色会呼吸、跑步时迈腿、起跳时被拉长、落地时被压扁并扬起烟尘,踩到敌人时画面停顿一下再震一震。游戏开发者常把这些统称为"果汁感"(juice)。它们不改变玩法,却决定了玩家按下按键后"感觉到了多少"。

下面的示例只显示本章的 5 个开关。建议先把它们全部关掉玩半分钟,再一个个打开。

关掉角色动画、粒子,把挤压拉伸、屏幕震动、命中停顿都设为 0,然后去踩第一只紫色史莱姆(在起点右边)。再全部恢复默认,踩下一只。两次的移动和判定完全一样,但第二次"踩"的感觉要清楚得多。面板下方的"测试震动"按钮可以单独触发一次震动。

这 5 个参数都在自动加载单例 Fx 里,游戏中任何地方都能读到它们:

fx/fx.gd第 9–13 行fx.gd:每种效果一个开关或强度在工程中查看 ↗
@export var animation := true        ## 角色动画(AnimationPlayer)
@export var squash := 1.0            ## 挤压拉伸强度,0 = 关
@export var particles := true        ## 粒子:烟尘、闪光、彩带
@export var shake := 1.0             ## 屏幕震动强度倍数,0 = 关
@export var hit_stop := 0.06         ## 命中停顿时长(秒),0 = 关

AnimationPlayer:轨道和关键帧

AnimationPlayer 是 Godot 里最通用的动画节点。它可以给任何节点的任何属性做关键帧动画:位置、旋转、颜色、可见性,也包括你自己脚本里 @export 的变量。一个动画(Animation 资源)由若干条轨道(track)组成,每条轨道对应一个属性,上面放着若干关键帧(时间 + 值)。播放时,引擎在关键帧之间插值,算出每一帧的属性值。

示例主角的节点结构是这样的:

Player (CharacterBody2D)
├─ Collision
├─ Visual        ← 挤压拉伸:Tween 改它的 scale
│  └─ Flip       ← 面朝方向:scale.x = ±1
│     └─ Rig     ← AnimationPlayer 改它的 position / rotation / scale
│        └─ Body ← 用 _draw() 画出来的角色(脚、眨眼、嫩芽)
└─ AnimationPlayer

为什么要套这么多层?因为不同系统都想改 scale:动画要做呼吸起伏,Tween 要做挤压拉伸,转身要翻转 x。如果它们改的是同一个节点的同一个属性,后写入的会覆盖先写入的。各管一层,效果就能叠加。

角色本身是用代码画的,脚的位置、眨眼程度、嫩芽角度都是 Body 脚本里的属性。属性的 setter 里调用 queue_redraw(),动画一改它,画面就重画:

player/player_body.gd第 17–32 行player_body.gd:这些属性可以直接被动画轨道驱动在工程中查看 ↗
@export var foot_l := Vector2(-6, 12):
	set(v):
		foot_l = v
		queue_redraw()
@export var foot_r := Vector2(6, 12):
	set(v):
		foot_r = v
		queue_redraw()
@export_range(0.0, 1.0) var blink := 0.0:
	set(v):
		blink = v
		queue_redraw()
@export var sprout_angle := 0.0:
	set(v):
		sprout_angle = v
		queue_redraw()

在编辑器里选中 AnimationPlayer,底部的动画面板会列出所有轨道。保存到 .tscn 里是这样的(跑步动画的第一条轨道:Rig 的位置,0.4 秒一个循环,上下颠两次):

player/player.tscn第 186–201 行player.tscn:run 动画的一条属性轨道在工程中查看 ↗
[sub_resource type="Animation" id="Animation_run"]
resource_name = "run"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Visual/Flip/Rig:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.1, 0.2, 0.3, 0.4),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(0, -15), Vector2(0, -17.5), Vector2(0, -15), Vector2(0, -17.5), Vector2(0, -15)]
}
  • length 和 loop_mode = 1:动画长 0.4 秒,循环播放。
  • path:节点路径:属性名,路径相对于 AnimationPlayer 的根节点(默认是它的父节点)。
  • interp:插值方式,1 是线性,2 是三次曲线(更圆滑);update = 0 表示连续更新。
  • times 和 values:关键帧的时间和值,一一对应。

调用方法轨道

除了属性轨道,还有调用方法轨道:播放到某个关键帧时,调用指定节点的一个方法。跑步动画在 0 秒和 0.2 秒(两只脚各着地一次)调用主角的 _footstep(),在脚下扬起一小撮灰:

player/player.tscn第 274–290 行player.tscn:run 动画里的调用方法轨道在工程中查看 ↗
tracks/7/type = "method"
tracks/7/imported = false
tracks/7/enabled = true
tracks/7/path = NodePath(".")
tracks/7/interp = 1
tracks/7/loop_wrap = true
tracks/7/keys = {
"times": PackedFloat32Array(0, 0.2),
"transitions": PackedFloat32Array(1, 1),
"values": [{
"args": [],
"method": &"_footstep"
}, {
"args": [],
"method": &"_footstep"
}]
}
player/player.gd第 214–216 行被动画调用的方法在工程中查看 ↗
func _footstep() -> void:
	if is_on_floor():
		Fx.puff(global_position + Vector2(-facing * 6.0, 0), _dust_color(), 2, Vector2(-facing, -0.4))

这样烟尘和脚步永远同步:跑得快时动画播放加速,烟尘也跟着变密。同样的办法常用来在攻击动画的某一帧开启伤害判定、在某一帧播放音效。

根据状态选择动画

示例有 RESET、idle、run、jump、fall、hurt 六个动画。每个物理帧根据角色状态决定播放哪个:

player/player.gd第 184–208 行按状态切换动画,同时切换表情在工程中查看 ↗
func _update_visual(delta: float) -> void:
	if dead:
		return
	flip.scale.x = facing
	body.look = Vector2(facing * minf(absf(velocity.x) / max_speed, 1.0), clampf(velocity.y / 600.0, -1.0, 1.0))
	_happy_timer -= delta
	if _happy_timer > 0.0:
		body.expression = PlayerBody.Face.HAPPY
	elif velocity.y > max_fall_speed * 0.8:
		body.expression = PlayerBody.Face.SURPRISED
	else:
		body.expression = PlayerBody.Face.NORMAL

	if not Fx.animation:
		return
	var next := "idle"
	if not is_on_floor():
		next = "jump" if velocity.y < 0.0 else "fall"
	elif absf(velocity.x) > 20.0:
		next = "run"
	if anim.current_animation != next:
		anim.play(next, 0.08)
	# 跑步动画的播放速度跟着实际速度走,脚步才不会"打滑"
	anim.speed_scale = clampf(absf(velocity.x) / 220.0, 0.6, 1.8) if next == "run" else 1.0
	Tuning.stat("动画", next)
  • anim.play(next, 0.08) 的第二个参数是混合时间:用 0.08 秒从上一个动画平滑过渡,而不是瞬间跳变。
  • 只有动画名变了才调用 play。每帧都 play 同一个动画不会重新开始,但写成"变了才切"意图更清楚。
  • speed_scale 按实际速度调整跑步动画快慢,避免"腿跑得飞快人却挪得很慢"的打滑感。
  • 表情不在动画里,而是代码直接改:吃到金币后 0.6 秒是笑脸,下落很快时是惊讶脸,受伤时是 ×× 眼。
RESET 动画是一个特殊约定:它存放所有被动画控制的属性的"默认值"。编辑器保存场景时会先应用 RESET,保证 .tscn 里存的是干净的默认状态,而不是你停在第 0.3 秒时的样子。示例在关闭"角色动画"时也会播放 RESET 再停用 AnimationPlayer,让角色恢复站姿。
从 Godot 3 迁移的坑:4.x 的动画都放在 AnimationLibrary 里,默认库名是空字符串,所以 play("run") 照常可用;如果你另外建了一个名为 enemy 的库,就要写 play("enemy/run")。另外 3.x 的 AnimationPlayer.playback_process_mode 在 4.x 改成了 callback_mode_process(定义在父类 AnimationMixer 上)。

AnimatedSprite2D 和 AnimationTree

还有两个相关节点,本示例没用,但需要知道它们的定位:

节点做什么什么时候用
AnimatedSprite2D按顺序切换 SpriteFrames 里的图片(逐帧动画)美术给的是一帧帧的像素图时最简单
AnimationPlayer任意属性的关键帧动画 + 方法调用骨骼式动画、UI 动画、过场;也可以驱动 AnimatedSprite2D 的 frame
AnimationTree在多个动画之间做状态机和混合(比如 BlendSpace 按速度混合走和跑)动画状态很多、过渡规则复杂时,代替上面那段 if 判断
Tween在代码里临时创建的一次性动画数值事先不知道、或者只用一次的效果(下一节)

Tween:在代码里做补间

AnimationPlayer 适合事先在编辑器里做好的动画。但很多效果的起点和终点运行时才知道:金币从当前位置飘起 28 像素,界面数字放大后弹回原样。这时用 Tween 更方便:create_tween() 创建一个补间,然后像列清单一样添加步骤。

snippets/tween_examples.gd第 5–10 行默认按顺序执行:一步结束再开始下一步在工程中查看 ↗
func chain_example() -> void:
	var tw := create_tween()                              # 补间绑定在这个节点上,节点被删除时自动停止
	tw.tween_property(self, "position:x", 300.0, 0.5)     # 第 1 步
	tw.tween_property(self, "rotation", PI, 0.3)          # 第 2 步:等第 1 步结束才开始
	tw.tween_interval(0.2)                                # 第 3 步:等 0.2 秒
	tw.tween_callback(queue_free)                         # 第 4 步:调用一个方法

tween_property(对象, "属性", 终值, 时长) 是最常用的步骤,属性可以用冒号访问分量,比如 "position:x"、"modulate:a"。要让几个步骤同时进行,用 set_parallel() 或 parallel():

snippets/tween_examples.gd第 15–24 行并行:set_parallel() / parallel() / chain()在工程中查看 ↗
func parallel_example() -> void:
	var tw := create_tween().set_parallel()               # 之后添加的步骤都同时开始
	tw.tween_property(self, "scale", Vector2(2, 2), 0.4)
	tw.tween_property(self, "modulate:a", 0.0, 0.4)
	tw.chain().tween_callback(queue_free)                 # chain():这一步等前面全部结束

	# 另一种写法:默认按顺序,只让某一步和上一步同时进行
	var tw2 := create_tween()
	tw2.tween_property(self, "position:y", -40.0, 0.3).as_relative()
	tw2.parallel().tween_property(self, "rotation", 0.5, 0.3)

示例里吃金币的效果就是一个并行补间:同时上飘、放大、淡出,最后 chain() 回到顺序模式,等前面都结束了再删除自己:

world/coin.gd第 31–46 行coin.gd:吃到金币在工程中查看 ↗
func _on_body_entered(body: Node2D) -> void:
	if _taken or not body is Player:
		return
	_taken = true
	body.collect_coin()
	Fx.sparkle(global_position, Color("#ffd84a"))
	set_deferred("monitoring", false)
	scale = Vector2.ONE
	# 并行:同时往上飘、放大、淡出;结束后删除自己
	var tw := create_tween().set_parallel()
	tw.tween_property(self, "position:y", position.y - 28.0, 0.35) \
			.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
	tw.tween_property(self, "scale", Vector2(1.6, 1.6), 0.35) \
			.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(self, "modulate:a", 0.0, 0.2).set_delay(0.15)
	tw.chain().tween_callback(queue_free)

缓动曲线:set_trans 和 set_ease

默认情况下补间是线性的:值匀速地从起点走到终点。真实物体的运动有加速和减速,所以补间通常要加缓动。Godot 把缓动拆成两个独立的选择:

  • set_trans(Tween.TRANS_*):曲线的形状。SINE、QUAD、CUBIC、QUART、QUINT、EXPO 越往后越"急";CIRC 是圆弧;BACK 会冲过终点再回来;ELASTIC 像弹簧来回振荡;BOUNCE 像球落地弹几下;SPRING 是 Godot 特有的弹簧曲线。
  • set_ease(Tween.EASE_*):形状用在哪一端。IN 是开头慢、结尾快;OUT 是开头快、结尾慢;IN_OUT 两头慢;OUT_IN 两头快、中间慢。

下面的小部件用的就是 Godot 源码里的公式。选一种组合,左边是曲线(横轴时间、纵轴值,虚线是线性),右边是同一条曲线分别驱动位置、缩放、旋转的样子:

几条经验:

  • 东西出现、弹出用 EASE_OUT(开始快、结束慢,像被扔出来后减速停下);东西离开、消失用 EASE_IN(慢慢启动然后加速离开)。
  • UI 弹出用 TRANS_BACK + EASE_OUT,冲过头一点再回来,显得有弹性;需要更夸张时用 TRANS_ELASTIC。
  • 来回循环的漂浮、呼吸用 TRANS_SINE,最柔和。
snippets/tween_examples.gd第 29–34 行给每一步单独设置缓动在工程中查看 ↗
func ease_example() -> void:
	var tw := create_tween()
	tw.tween_property(self, "position:x", 400.0, 0.6) \
			.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)     # 冲过头一点再退回来
	tw.tween_property(self, "position:x", 0.0, 0.6) \
			.set_trans(Tween.TRANS_BOUNCE).set_ease(Tween.EASE_OUT)   # 像球落地一样弹几下

界面上金币数和"失误"数字变化时会弹一下,就是放大后用 ELASTIC 弹回:

ui/hud.gd第 47–53 行hud.gd:UI 弹跳在工程中查看 ↗
## UI 弹跳:先瞬间放大,再用 ELASTIC 缓动弹回原大小。
## Control 默认绕左上角缩放,所以先把 pivot_offset 设到中心。
func pop(c: Control, amount := 1.6) -> void:
	c.pivot_offset = c.size / 2.0
	c.scale = Vector2(amount, amount)
	var tw := c.create_tween()
	tw.tween_property(c, "scale", Vector2.ONE, 0.5).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)

循环、等待和打断

snippets/tween_examples.gd第 39–49 行set_loops() 循环,await tw.finished 等待结束在工程中查看 ↗
func loop_example() -> void:
	var tw := create_tween().set_loops()                  # 不传次数 = 无限循环
	tw.tween_property(self, "position:y", -6.0, 0.5).as_relative().set_trans(Tween.TRANS_SINE)
	tw.tween_property(self, "position:y", 6.0, 0.5).as_relative().set_trans(Tween.TRANS_SINE)


func await_example() -> void:
	var tw := create_tween()
	tw.tween_property(self, "modulate:a", 0.0, 0.5)
	await tw.finished                                     # 等补间播完再往下执行
	queue_free()
snippets/tween_examples.gd第 54–61 行重新开始前先 kill() 掉旧的补间在工程中查看 ↗
var _tween: Tween


func restart_tween() -> void:
	if _tween:
		_tween.kill()          # 先停掉旧的;两个补间同时改一个属性,结果会互相打架
	_tween = create_tween()
	_tween.tween_property(self, "scale", Vector2.ONE, 0.2).from(Vector2(1.3, 0.7))
两个补间抢同一个属性:玩家连续快速跳跃时,上一次的挤压拉伸补间还没播完,新的又开始了。两个补间每帧都会写 scale,结果是闪烁或者停在奇怪的大小。解决办法就是上面的写法:把补间存进变量,开始新的之前先 kill()。另外,create_tween() 创建的补间绑定在调用它的节点上,节点被删除时补间自动停止;用 get_tree().create_tween() 创建的则不会,节点删除后补间再访问它就会报错。
Godot 3 的写法不能用了:3.x 需要先添加一个 Tween 节点,再调用 interpolate_property(...) 和 start()。4.x 里 Tween 不再是节点,只能通过 create_tween() 创建,创建后在下一帧自动开始,播放完自动释放,不能重复使用。

下面这段代码执行后,节点的透明度什么时候开始变化?
var tw := create_tween()
tw.tween_property(self, "position:x", 100.0, 1.0)
tw.parallel().tween_property(self, "scale", Vector2(2, 2), 0.5)
tw.tween_property(self, "modulate:a", 0.0, 0.5)

  1. 立刻
  2. 0.5 秒后
  3. 1 秒后
  4. 1.5 秒后

前两步并行,这一组要等最长的那步(1 秒的移动)结束才算完成;第三步没有 parallel(),所以在 1 秒后开始。

挤压与拉伸

这是传统动画的第一条原则:物体在受力时变形。起跳时被拉长、落地时被压扁,会让一个硬邦邦的方块显得有弹性、有重量。做法是把 Visual 节点的 scale 瞬间设成变形后的值,再用补间弹回 (1, 1):

player/player.gd第 168–179 行挤压拉伸:瞬间变形,再弹回在工程中查看 ↗
## 挤压拉伸:先把 Visual 瞬间缩放到 to,再用 Tween 弹回 (1, 1)。
## Fx.squash 是强度:0 = 关闭,1 = 原样,2 = 加倍夸张。
func squash(to: Vector2, duration := 0.25) -> void:
	if Fx.squash <= 0.0:
		visual.scale = Vector2.ONE
		return
	if _squash_tween:
		_squash_tween.kill()
	visual.scale = Vector2.ONE.lerp(to, Fx.squash)
	_squash_tween = create_tween()
	_squash_tween.tween_property(visual, "scale", Vector2.ONE, duration) \
			.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

变形时让 x 和 y 一个变大一个变小,大致保持 x × y ≈ 1(面积不变),看起来像是同一团东西被挤压,而不是在忽大忽小。强度用 Vector2.ONE.lerp(to, Fx.squash) 控制:0 就是 (1, 1),不变形;2 是变形量翻倍。由于 Visual 的原点在角色脚底,缩放时脚底不动,看起来就是"蹲下去"而不是"缩进地里"。

什么时候触发?起跳和落地两个事件:

player/player.gd第 152–163 行起跳和落地时的效果,落地强度取决于下落速度在工程中查看 ↗
func _on_jumped() -> void:
	squash(Vector2(0.7, 1.35))
	Fx.puff(global_position, _dust_color(), 6, Vector2.UP)


func _on_landed() -> void:
	# 下落越快,压得越扁、烟尘越多
	var k := clampf(_fall_speed / max_fall_speed, 0.0, 1.0)
	squash(Vector2(1.0 + 0.45 * k, 1.0 - 0.4 * k))
	Fx.puff(global_position, _dust_color(), int(4 + 10 * k), Vector2.ZERO)
	if k > 0.85:
		Fx.add_trauma(0.25)

落地效果的强弱由落地前的下落速度决定:轻轻落在矮台阶上只压扁一点,从高处摔下来压得很扁、烟尘很多,速度接近上限时还会轻微震屏。效果的强度和事件的力度对应起来,玩家才能"读"出发生了什么。

把挤压拉伸拖到 2,从高台上跳下来,观察落地的形变;再拖到 0 对比。注意角色的碰撞形状并没有变,变的只是 Visual 这一层。

粒子

示例里的烟尘、脚步灰、金币闪光、史莱姆被踩扁时的碎片,都是 CPUParticles2D。粒子节点每秒发射一定数量的小图片,每个粒子有自己的速度、寿命、大小和颜色变化。烟尘的配置保存在一个单独的场景里:

fx/dust.tscn第 13–35 行fx/dust.tscn:一次性的烟尘粒子在工程中查看 ↗
[node name="Dust" type="CPUParticles2D"]
emitting = false
amount = 8
lifetime = 0.45
one_shot = true
explosiveness = 1.0
randomness = 0.3
lifetime_randomness = 0.3
local_coords = false
texture = ExtResource("1")
emission_shape = 3
emission_rect_extents = Vector2(8, 1)
direction = Vector2(0, -1)
spread = 80.0
gravity = Vector2(0, -60)
initial_velocity_min = 40.0
initial_velocity_max = 110.0
damping_min = 120.0
damping_max = 200.0
scale_amount_min = 0.7
scale_amount_max = 1.4
scale_amount_curve = SubResource("Curve_size")
color_ramp = SubResource("Gradient_fade")
  • one_shot = true + explosiveness = 1.0:所有粒子在同一瞬间发射出去,只发一次,适合"爆一下"的效果。持续的效果(比如火焰)则用 one_shot = false。
  • local_coords = false:粒子发出后留在世界坐标里,不会跟着发射器移动。烟尘应该留在落地点,而不是跟着角色跑。
  • direction + spread:发射方向和散开的角度;gravity 设成向上的小值,让灰尘慢慢飘起;damping 让它很快减速。
  • scale_amount_curve 和 color_ramp:粒子在一生中先变大再缩小、逐渐透明。

需要时实例化一份、设置颜色和数量、加进场景树,播完后靠 finished 信号自动删除:

fx/fx.gd第 49–62 行fx.gd:放一次性粒子在工程中查看 ↗
## 在 pos 处放一次性粒子:实例化场景 → 设置 → 加进场景树 → 播完自动删除。
func burst(scene: PackedScene, pos: Vector2, color: Color, amount: int, dir := Vector2.ZERO) -> void:
	if not particles or amount <= 0:
		return
	var p := scene.instantiate() as CPUParticles2D
	p.amount = amount
	p.color = color
	if dir != Vector2.ZERO:
		p.direction = dir
		p.spread = 30.0
	p.global_position = pos
	get_tree().current_scene.add_child(p)
	p.emitting = true
	p.finished.connect(p.queue_free)

烟尘的颜色不是写死的:它从角色脚下那一格瓦片的自定义数据 dust_color 里读出来,草地是浅绿灰色,木平台是木屑色。这个做法第 8 章会详细讲。

GPUParticles2D 用显卡计算,能同时处理成千上万个粒子,但它需要 ParticleProcessMaterial,在 Web 导出和低端设备上兼容性不如 CPU 版本。像烟尘这种一次几个到几十个粒子的效果,用 CPUParticles2D 完全够用。两者可以在编辑器里一键互相转换。

屏幕震动:trauma 模型

最简单的震屏写法是"震动期间每帧给相机一个随机偏移"。问题有两个:每帧随机的偏移看起来是在高频抖动,而不是在晃;多个事件叠加时也不好控制强度。游戏开发者 Squirrel Eiserloh 在 GDC 演讲中介绍的 trauma(创伤值)模型解决了这些问题:

  1. 维护一个 0 到 1 之间的 trauma。每次受到冲击,就往上加一点(踩敌人 +0.35,受伤 +0.6),上限为 1。
  2. trauma 每秒按固定速度线性衰减。
  3. 实际震动幅度 shake = trauma²(或三次方)。平方让小冲击几乎不晃,大冲击晃得厉害,衰减到尾声时也自然地收住。
  4. 偏移 = 最大偏移 × shake × 噪声(时间)。用连续变化的噪声(示例用 FastNoiseLite)而不是每帧随机数,画面是平滑地晃,而不是乱跳。旋转也同样处理。
fx/game_camera.gd第 90–105 行game_camera.gd:trauma 震动在工程中查看 ↗
## trauma(创伤值)模型:受击时累加 trauma(0~1),随时间线性衰减;
## 实际震动幅度 = trauma²,小冲击几乎不晃,大冲击晃得厉害,衰减也显得自然。
## 偏移用连续噪声而不是每帧随机数,画面是"晃"而不是"抖"。
func add_trauma(amount: float) -> void:
	trauma = minf(trauma + amount, 1.0)


func _update_shake(delta: float) -> void:
	trauma = maxf(trauma - trauma_decay * delta, 0.0)
	_time += delta * 30.0
	var s := trauma * trauma * Fx.shake
	offset = Vector2(
		max_offset.x * s * _noise.get_noise_2d(1.0, _time),
		max_offset.y * s * _noise.get_noise_2d(100.0, _time))
	rotation = max_roll * s * _noise.get_noise_2d(200.0, _time)
	Tuning.stat("trauma", snappedf(trauma, 0.01))

震动改的是 Camera2D 的 offset 和 rotation,不改相机的位置,所以不会干扰跟随和平滑逻辑(第 8 章)。要让旋转生效,相机的 ignore_rotation 必须设为 false。

在示例里点"测试震动",再把屏幕震动拖到 2 再点一次。然后在上面的小部件里把指数设为 1:小冲击也会明显晃动,衰减的尾巴拖得很长;设为 3 则只有大冲击才会晃。
震动是很容易用过头的效果。经常发生的事件(每次落地、每次开枪)只给很小的 trauma 或者不给;把明显的震动留给真正重要的时刻。另外,有些玩家对屏幕晃动敏感,正式的游戏应该在设置里提供关闭震动的选项——示例里的"屏幕震动"滑块就是这样一个总开关。

命中停顿

踩到敌人的一瞬间,整个游戏冻结几十毫秒,然后继续。这个停顿短到玩家不会意识到"卡了",但它让撞击的那一刻被"看清楚",打击感会明显增强。格斗游戏和动作游戏几乎都有这个设计(也叫 hit stop 或 hit pause)。

在 Godot 里最简单的实现是改 Engine.time_scale:它是整个引擎的时间倍率,设为 0.05 时,所有 delta、物理、动画、补间、粒子都放慢到 1/20。然后用一个不受时间倍率影响的计时器在真实时间到了之后恢复:

fx/fx.gd第 29–39 行fx.gd:命中停顿在工程中查看 ↗
## 命中停顿:把整个游戏的时间流速压到接近 0,过一小会儿再恢复。
## 计时器的第 4 个参数 ignore_time_scale = true,否则它自己也会被"冻住"。
func freeze(duration: float) -> void:
	if duration <= 0.0:
		return
	_freeze_id += 1
	var id := _freeze_id
	Engine.time_scale = 0.05
	await get_tree().create_timer(duration, true, false, true).timeout
	if id == _freeze_id:      # 期间又触发了新的停顿,就让新的那次负责恢复
		Engine.time_scale = 1.0

create_timer(时长, process_always, process_in_physics, ignore_time_scale) 的第四个参数是关键:如果不设为 true,计时器本身也会被放慢 20 倍,停顿就变成了 1 秒多。_freeze_id 用来处理"停顿期间又触发了一次停顿":只有最后一次负责把时间恢复。

把这些效果组合起来,就是踩史莱姆的完整反馈:停顿、震屏、碎片、史莱姆被压扁再淡出,同时主角被弹起并露出笑脸。

world/slime.gd第 45–69 行slime.gd:踩扁敌人在工程中查看 ↗
func _on_hitbox_body_entered(body: Node2D) -> void:
	if _dead or not body is Player or body.dead:
		return
	var player := body as Player
	# 玩家正在下落,并且脚比史莱姆的上半身高 → 算"踩"。
	# 区域的 body_entered 信号会比真正接触晚一两个物理帧,所以判定线放得宽一点(身体中间偏下)。
	if player.velocity.y > 0.0 and player.global_position.y < global_position.y - 3.0:
		_squash_die()
		player.bounce()
	else:
		player.die()


func _squash_die() -> void:
	_dead = true
	set_deferred("collision_layer", 0)
	hitbox.set_deferred("monitoring", false)
	Fx.freeze(Fx.hit_stop)
	Fx.add_trauma(0.35)
	Fx.sparkle(global_position + Vector2(0, -10), Color("#b693ff"), 14)
	var tw := create_tween()
	tw.tween_property($Body, "scale", Vector2(1.6, 0.25), 0.08)
	tw.tween_interval(0.25)
	tw.tween_property($Body, "modulate:a", 0.0, 0.2)
	tw.tween_callback(queue_free)
把命中停顿拖到 0.25 秒去踩史莱姆,停顿会长到能明显看出来,这时它就不再是"打击感"而是"卡顿"了。一般取 0.03 到 0.1 秒,重要的攻击可以更长一点。
Engine.time_scale 是全局的,会一直保持到你改回来。如果在停顿期间切换了场景或者重新开始关卡,新场景也会是慢动作。示例在 main.gd 的 restart() 里先把它设回 1.0 再重新加载场景。需要在慢动作中照常运行的节点(比如暂停菜单),可以把 process_mode 设为 PROCESS_MODE_ALWAYS,但注意这只影响暂停,不影响 time_scale——它们的 delta 仍然会被缩放。

trauma 模型里,为什么实际幅度用 trauma² 而不是直接用 trauma?

  1. 为了让计算更快
  2. 让小冲击晃得很少、大冲击晃得明显,衰减末尾也更自然
  3. 为了让幅度永远不超过 1

0.3 的平方是 0.09,1 的平方还是 1。平方放大了大小冲击之间的差别,同时让 trauma 快衰减完时幅度迅速趋近于 0,不会有一段长长的微弱抖动。幅度不超过 1 是由 trauma 本身的上限保证的。

AnimationPlayer 的 idle 动画在改 Rig 的 scale,如果挤压拉伸的 Tween 也改 Rig 的 scale,会发生什么?

  1. 两者每帧互相覆盖,挤压拉伸几乎看不到或者闪烁
  2. 两个缩放会自动相乘
  3. Godot 报错,禁止两个系统改同一个属性

属性就是一个值,谁最后写入就是谁的。所以示例把挤压拉伸放在 Visual、动画放在 Rig,父子节点的缩放才会相乘叠加。

小结

  • AnimationPlayer 用轨道 + 关键帧驱动任意属性,调用方法轨道能让代码和动画的某一帧同步。
  • Tween 用于运行时才知道数值的一次性动画:默认顺序、parallel() 并行、set_trans 选曲线形状、set_ease 选作用在哪一端。
  • 挤压拉伸:瞬间变形、补间弹回,强度和事件力度对应;不同系统改不同层级的节点。
  • 粒子用一次性的 CPUParticles2D 场景,local_coords = false 让它留在原地。
  • 屏幕震动用 trauma 模型:累加、衰减、平方、平滑噪声。
  • 命中停顿:短暂降低 Engine.time_scale,用忽略时间倍率的计时器恢复。

下一章回到关卡本身:地形瓦片怎么配置、自动连接怎么实现、相机怎么跟随才舒服。