动画、补间与手感
同样的移动参数,加上角色动画、Tween 补间、挤压拉伸、粒子、屏幕震动和命中停顿之后,游戏的"手感"会完全不同。本章讲清楚每种效果的原理和写法,并且都可以单独开关对比。
上一章调的是"角色怎么动"。这一章不改任何移动参数,只加反馈:角色会呼吸、跑步时迈腿、起跳时被拉长、落地时被压扁并扬起烟尘,踩到敌人时画面停顿一下再震一震。游戏开发者常把这些统称为"果汁感"(juice)。它们不改变玩法,却决定了玩家按下按键后"感觉到了多少"。
下面的示例只显示本章的 5 个开关。建议先把它们全部关掉玩半分钟,再一个个打开。
这 5 个参数都在自动加载单例 Fx 里,游戏中任何地方都能读到它们:
@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(),动画一改它,画面就重画:
@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 秒一个循环,上下颠两次):
[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(),在脚下扬起一小撮灰:
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"
}]
}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 六个动画。每个物理帧根据角色状态决定播放哪个:
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 秒是笑脸,下落很快时是惊讶脸,受伤时是 ×× 眼。
.tscn 里存的是干净的默认状态,而不是你停在第 0.3 秒时的样子。示例在关闭"角色动画"时也会播放 RESET 再停用 AnimationPlayer,让角色恢复站姿。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() 创建一个补间,然后像列清单一样添加步骤。
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():
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() 回到顺序模式,等前面都结束了再删除自己:
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,最柔和。
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 弹跳:先瞬间放大,再用 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)循环、等待和打断
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()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() 创建的则不会,节点删除后补间再访问它就会报错。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)
- 立刻
- 0.5 秒后
- 1 秒后
- 1.5 秒后
前两步并行,这一组要等最长的那步(1 秒的移动)结束才算完成;第三步没有 parallel(),所以在 1 秒后开始。
挤压与拉伸
这是传统动画的第一条原则:物体在受力时变形。起跳时被拉长、落地时被压扁,会让一个硬邦邦的方块显得有弹性、有重量。做法是把 Visual 节点的 scale 瞬间设成变形后的值,再用补间弹回 (1, 1):
## 挤压拉伸:先把 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 的原点在角色脚底,缩放时脚底不动,看起来就是"蹲下去"而不是"缩进地里"。
什么时候触发?起跳和落地两个事件:
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)落地效果的强弱由落地前的下落速度决定:轻轻落在矮台阶上只压扁一点,从高处摔下来压得很扁、烟尘很多,速度接近上限时还会轻微震屏。效果的强度和事件的力度对应起来,玩家才能"读"出发生了什么。
Visual 这一层。粒子
示例里的烟尘、脚步灰、金币闪光、史莱姆被踩扁时的碎片,都是 CPUParticles2D。粒子节点每秒发射一定数量的小图片,每个粒子有自己的速度、寿命、大小和颜色变化。烟尘的配置保存在一个单独的场景里:
[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 信号自动删除:
## 在 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(创伤值)模型解决了这些问题:
- 维护一个 0 到 1 之间的
trauma。每次受到冲击,就往上加一点(踩敌人 +0.35,受伤 +0.6),上限为 1。 trauma每秒按固定速度线性衰减。- 实际震动幅度
shake = trauma²(或三次方)。平方让小冲击几乎不晃,大冲击晃得厉害,衰减到尾声时也自然地收住。 - 偏移 = 最大偏移 × shake × 噪声(时间)。用连续变化的噪声(示例用
FastNoiseLite)而不是每帧随机数,画面是平滑地晃,而不是乱跳。旋转也同样处理。
## 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。
命中停顿
踩到敌人的一瞬间,整个游戏冻结几十毫秒,然后继续。这个停顿短到玩家不会意识到"卡了",但它让撞击的那一刻被"看清楚",打击感会明显增强。格斗游戏和动作游戏几乎都有这个设计(也叫 hit stop 或 hit pause)。
在 Godot 里最简单的实现是改 Engine.time_scale:它是整个引擎的时间倍率,设为 0.05 时,所有 delta、物理、动画、补间、粒子都放慢到 1/20。然后用一个不受时间倍率影响的计时器在真实时间到了之后恢复:
## 命中停顿:把整个游戏的时间流速压到接近 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.0create_timer(时长, process_always, process_in_physics, ignore_time_scale) 的第四个参数是关键:如果不设为 true,计时器本身也会被放慢 20 倍,停顿就变成了 1 秒多。_freeze_id 用来处理"停顿期间又触发了一次停顿":只有最后一次负责把时间恢复。
把这些效果组合起来,就是踩史莱姆的完整反馈:停顿、震屏、碎片、史莱姆被压扁再淡出,同时主角被弹起并露出笑脸。
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)Engine.time_scale 是全局的,会一直保持到你改回来。如果在停顿期间切换了场景或者重新开始关卡,新场景也会是慢动作。示例在 main.gd 的 restart() 里先把它设回 1.0 再重新加载场景。需要在慢动作中照常运行的节点(比如暂停菜单),可以把 process_mode 设为 PROCESS_MODE_ALWAYS,但注意这只影响暂停,不影响 time_scale——它们的 delta 仍然会被缩放。trauma 模型里,为什么实际幅度用 trauma² 而不是直接用 trauma?
- 为了让计算更快
- 让小冲击晃得很少、大冲击晃得明显,衰减末尾也更自然
- 为了让幅度永远不超过 1
0.3 的平方是 0.09,1 的平方还是 1。平方放大了大小冲击之间的差别,同时让 trauma 快衰减完时幅度迅速趋近于 0,不会有一段长长的微弱抖动。幅度不超过 1 是由 trauma 本身的上限保证的。
AnimationPlayer 的 idle 动画在改 Rig 的 scale,如果挤压拉伸的 Tween 也改 Rig 的 scale,会发生什么?
- 两者每帧互相覆盖,挤压拉伸几乎看不到或者闪烁
- 两个缩放会自动相乘
- Godot 报错,禁止两个系统改同一个属性
属性就是一个值,谁最后写入就是谁的。所以示例把挤压拉伸放在 Visual、动画放在 Rig,父子节点的缩放才会相乘叠加。
小结
AnimationPlayer用轨道 + 关键帧驱动任意属性,调用方法轨道能让代码和动画的某一帧同步。Tween用于运行时才知道数值的一次性动画:默认顺序、parallel()并行、set_trans选曲线形状、set_ease选作用在哪一端。- 挤压拉伸:瞬间变形、补间弹回,强度和事件力度对应;不同系统改不同层级的节点。
- 粒子用一次性的
CPUParticles2D场景,local_coords = false让它留在原地。 - 屏幕震动用 trauma 模型:累加、衰减、平方、平滑噪声。
- 命中停顿:短暂降低
Engine.time_scale,用忽略时间倍率的计时器恢复。
下一章回到关卡本身:地形瓦片怎么配置、自动连接怎么实现、相机怎么跟随才舒服。