角色动画
这是最后一课,我们会使用 Godot 的内置动画工具制作角色的浮动和拍打动画。你会学到如何在编辑器中设计动画,以及如何使用代码让游戏变得活灵活现。
我们将会开始介绍动画编辑器的使用。
动画编辑器的使用
该引擎自带的工具可以在编辑器中编写动画。然后你可以在运行时使用代码来播放和控制它们。
打开玩家场景,选中 Player
节点,然后添加一个 AnimationPlayer 节点。
动画停靠面板就会出现在底部面板中。
它的特点是顶部有一个工具栏和动画下拉菜单,中间有一个轨道编辑器,目前是空的,底部有过滤、捕捉和缩放选项。
让我们来创建一个动画。请点击动画 -> 新建。
将动画命名为“float”(漂浮)。
创建动画后,将显示时间轴,其中数字表示以秒为单位的时间。
我们希望让这个动画在游戏开始时自动开始播放,而且还应该循环播放。
要执行此操作,你可以单击动画工具栏上的自动播放按钮()和循环箭头。
你还可以单击右上角的图钉图标,将动画编辑器进行固定。这样它就不会在你点击视口取消选择节点时折叠。
在面板右上角将动画的时长设为 1.2
秒。
你应该看到灰色带子变宽了一点。它显示动画的开始和结束,垂直蓝线是你的时间光标。
单击并拖拽右下角的滑动条,即可将时间线进行缩放。
漂浮动画
使用动画播放器节点,你可以对所需任意数量的节点的大多数属性做动画。请注意检查器中属性旁的钥匙图标。在上面单击就可以创建一个关键帧,即对应属性的一对时间与值。关键帧会被插入到时间线上的时间光标处。
让我们来开始插入帧吧。这里,我们要为 Character
节点的位置(position)和旋转(rotation)做动画。
选中 Character
并在检查器中展开 Transform 栏。单击 Position 和 Rotation 旁的钥匙图标。

对于本教程,我们只创建默认选择 RESET(重置)轨道
编辑器中会出现两个轨道,各有一个代表关键帧的菱形图标。
你可以在菱形滑块上单击并拖动,以移动它们的时间。将位置(position )帧移动到 0.3
秒处,将旋转(rotation )帧移动到 0.1
秒处。
在灰色的时间线上单击并拖拽,将时间光标移动至 0.5
秒位置。
在 检查器 中,将 Position 的 Y 轴设置为 0.65
米,将 Rotation 的 X 轴设置为 8
。
如果你在检查器面板中没有看到属性,请在场景面板中再次点击 Character
节点。
为这两个属性分别创建一个关键帧
现在开始在时间线上拖动,将位置(position)的关键帧移动到 0.7
秒。
备注
关于动画原理的讲解已经超出了本教程的范围。请注意,你不想均匀地分配时间和空间。取而代之的是,动画师使用时间和间隔,这两个核心动画原则。你希望让它们存在一定的偏移,在角色的运动中产生对比,以使他们感觉生动。
将时间光标移动到动画结尾,即 1.2
秒。将 Y 平移量设为约 0.35
、X 旋转量设为 -9
度。再次为这两个属性添加帧。
单击播放按钮或者按 Shift + D 即可预览结果。单击停止按钮或者按 S 即可停止播放。
你可以看到引擎通过在关键帧之间插值来生成连续动画。不过目前,生成的动作非常机械。这是因为默认的插值是线性的,会导致持续的过渡,并且与现实世界中生物的移动方式不同。
我们可以使用缓动曲线来控制关键帧之间的过渡。
单击并拖拽,框选时间线上的前两个帧。
可以在检查器中同时编辑这两个帧的属性,其中就有一个属性叫做 Easing(缓动)。
单击并拖动曲线,把它往左拉。这样就会让他实现缓出,也就是说,一开始变得快,然后时间光标越接近下一个关键帧就变得越慢。
再次播放动画以查看差异。前半部分应该已经感觉有点弹性了。
将缓动效果应用于旋转轨迹中的第二个关键帧。
对第二个平移关键帧执行相反操作,将其拖动到右侧。
你的动画应该类似这样。
备注
每一帧,动画都会去更新被动画的节点的属性,覆盖掉初始值。如果我们直接对 Player 节点做动画,就没法使用代码来移动它了。这就是 Pivot 节点的用处:尽管我们为 Character 做了动画,我们还是可以在此动画之上,再通过代码来移动并旋转 Pivot。
如果你运行游戏,玩家的生物就会漂浮起来!
如果这个生物离地面太近了,你可以将 Pivot
向上移动,达成偏移的目的。
使用代码控制动画
我们可以使用代码来根据玩家的输入控制动画的播放。让我们在角色移动时修改动画的速度吧。
点击 Player
旁的脚本图标打开其脚本。
在 _physics_process()
中检查 direction
向量的那一行之后添加如下代码。
func _physics_process(delta):
#...
if direction != Vector3.ZERO:
#...
$AnimationPlayer.speed_scale = 4
else:
$AnimationPlayer.speed_scale = 1
public override void _PhysicsProcess(double delta)
{
// ...
if (direction != Vector3.Zero)
{
// ...
GetNode<AnimationPlayer>("AnimationPlayer").SpeedScale = 4;
}
else
{
GetNode<AnimationPlayer>("AnimationPlayer").SpeedScale = 1;
}
}
这段代码的作用是让玩家在移动时将播放速度乘以 4
。在停止移动时将其恢复原状。
我们提到 Pivot(轴心)可以在动画之上叠加变换。我们可以用下面这行代码使角色在跳跃时产生弧线。把它加在 _physics_process()
的最后。
func _physics_process(delta):
#...
$Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse
public override void _PhysicsProcess(double delta)
{
// ...
var pivot = GetNode<Node3D>("Pivot");
pivot.Rotation = new Vector3(Mathf.Pi / 6.0f * Velocity.Y / JumpImpulse, pivot.Rotation.Y, pivot.Rotation.Z);
}
为小怪制作动画
在 Godot 中还有一个很好的动画技巧:只要你使用类似的节点结构,你就可以把它们复制到不同的场景中。
例如,Mob
和 Player
场景都有 Pivot
和 Character
节点,所以我们可以在它们之间复用动画。
打开 Player 场景,选择 AnimationPlayer 节点,然后点击动画 > 管理动画...。点击 float 动画旁边的 将动画复制到剪贴板 按钮(两个小方块)。点击“确定”关闭窗口。
然后打开 mob.tscn
,创建一个 AnimationPlayer 子节点并且选中它。点击 动画 > 管理动画,然后新建库,你应该看到信息“将创建全局库”。文本框处留白然后点击确定。点击粘贴图标(剪贴板)后就会出现在窗口中。点击确定关闭窗口。
接下来,确保底部动画编辑器面板中的自动播放按钮()和循环箭头(动画循环)也已打开。这样就行了;所有的怪物现在就都能播放 float 动画了。
我们可以根据生物的 random_speed
来更改播放速度。打开 Mob 的脚本,在 initialize()
函数的末尾添加下面这行代码。
func initialize(start_position, player_position):
#...
$AnimationPlayer.speed_scale = random_speed / min_speed
public void Initialize(Vector3 startPosition, Vector3 playerPosition)
{
// ...
GetNode<AnimationPlayer>("AnimationPlayer").SpeedScale = randomSpeed / MinSpeed;
}
这样,你就完成了你第一个完整 3D 游戏的编码。
恭喜!
在下一部分,我们将快速复习已学到的内容,并为你提供一些继续学习的链接。不过现在,这里是完整的 player.gd
和 mob.gd
,可以用来校对你的代码。
这是 Player 脚本。
extends CharacterBody3D
signal hit
# How fast the player moves in meters per second.
@export var speed = 14
# The downward acceleration while in the air, in meters per second squared.
@export var fall_acceleration = 75
# Vertical impulse applied to the character upon jumping in meters per second.
@export var jump_impulse = 20
# Vertical impulse applied to the character upon bouncing over a mob
# in meters per second.
@export var bounce_impulse = 16
var target_velocity = Vector3.ZERO
func _physics_process(delta):
# We create a local variable to store the input direction
var direction = Vector3.ZERO
# We check for each move input and update the direction accordingly
if Input.is_action_pressed("move_right"):
direction.x = direction.x + 1
if Input.is_action_pressed("move_left"):
direction.x = direction.x - 1
if Input.is_action_pressed("move_back"):
# Notice how we are working with the vector's x and z axes.
# In 3D, the XZ plane is the ground plane.
direction.z = direction.z + 1
if Input.is_action_pressed("move_forward"):
direction.z = direction.z - 1
# Prevent diagonal movement being very fast
if direction != Vector3.ZERO:
direction = direction.normalized()
$Pivot.look_at(position + direction,Vector3.UP)
$AnimationPlayer.speed_scale = 4
else:
$AnimationPlayer.speed_scale = 1
# Ground Velocity
target_velocity.x = direction.x * speed
target_velocity.z = direction.z * speed
# Vertical Velocity
if not is_on_floor(): # If in the air, fall towards the floor
target_velocity.y = target_velocity.y - (fall_acceleration * delta)
# Jumping.
if is_on_floor() and Input.is_action_just_pressed("jump"):
target_velocity.y = jump_impulse
# Iterate through all collisions that occurred this frame
# in C this would be for(int i = 0; i < collisions.Count; i++)
for index in range(get_slide_collision_count()):
# We get one of the collisions with the player
var collision = get_slide_collision(index)
# If the collision is with ground
if collision.get_collider() == null:
continue
# If the collider is with a mob
if collision.get_collider().is_in_group("mob"):
var mob = collision.get_collider()
# we check that we are hitting it from above.
if Vector3.UP.dot(collision.get_normal()) > 0.1:
# If so, we squash it and bounce.
mob.squash()
target_velocity.y = bounce_impulse
# Prevent further duplicate calls.
break
# Moving the Character
velocity = target_velocity
move_and_slide()
$Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse
# And this function at the bottom.
func die():
hit.emit()
queue_free()
func _on_mob_detector_body_entered(body):
die()
using Godot;
public partial class Player : CharacterBody3D
{
// Emitted when the player was hit by a mob.
[Signal]
public delegate void HitEventHandler();
// How fast the player moves in meters per second.
[Export]
public int Speed { get; set; } = 14;
// The downward acceleration when in the air, in meters per second squared.
[Export]
public int FallAcceleration { get; set; } = 75;
// Vertical impulse applied to the character upon jumping in meters per second.
[Export]
public int JumpImpulse { get; set; } = 20;
// Vertical impulse applied to the character upon bouncing over a mob in meters per second.
[Export]
public int BounceImpulse { get; set; } = 16;
private Vector3 _targetVelocity = Vector3.Zero;
public override void _PhysicsProcess(double delta)
{
// We create a local variable to store the input direction.
var direction = Vector3.Zero;
// We check for each move input and update the direction accordingly.
if (Input.IsActionPressed("move_right"))
{
direction.X += 1.0f;
}
if (Input.IsActionPressed("move_left"))
{
direction.X -= 1.0f;
}
if (Input.IsActionPressed("move_back"))
{
// Notice how we are working with the vector's X and Z axes.
// In 3D, the XZ plane is the ground plane.
direction.Z += 1.0f;
}
if (Input.IsActionPressed("move_forward"))
{
direction.Z -= 1.0f;
}
// Prevent diagonal movement being very fast.
if (direction != Vector3.Zero)
{
direction = direction.Normalized();
GetNode<Node3D>("Pivot").LookAt(Position + direction, Vector3.Up);
GetNode<AnimationPlayer>("AnimationPlayer").PlaybackSpeed = 4;
}
else
{
GetNode<AnimationPlayer>("AnimationPlayer").PlaybackSpeed = 1;
}
// Ground velocity
_targetVelocity.X = direction.X * Speed;
_targetVelocity.Z = direction.Z * Speed;
// Vertical velocity
if (!IsOnFloor())
{
_targetVelocity.Y -= FallAcceleration * (float)delta;
}
// Jumping.
if (IsOnFloor() && Input.IsActionJustPressed("jump"))
{
_targetVelocity.Y += JumpImpulse;
}
// Iterate through all collisions that occurred this frame.
for (int index = 0; index < GetSlideCollisionCount(); index++)
{
// We get one of the collisions with the player.
KinematicCollision3D collision = GetSlideCollision(index);
// If the collision is with a mob.
if (collision.GetCollider() is Mob mob)
{
// We check that we are hitting it from above.
if (Vector3.Up.Dot(collision.GetNormal()) > 0.1f)
{
// If so, we squash it and bounce.
mob.Squash();
_targetVelocity.Y = BounceImpulse;
// Prevent further duplicate calls.
break;
}
}
}
// Moving the character
Velocity = _targetVelocity;
MoveAndSlide();
var pivot = GetNode<Node3D>("Pivot");
pivot.Rotation = new Vector3(Mathf.Pi / 6.0f * Velocity.Y / JumpImpulse, pivot.Rotation.Y, pivot.Rotation.Z);
}
private void Die()
{
EmitSignal(SignalName.Hit);
QueueFree();
}
private void OnMobDetectorBodyEntered(Node body)
{
Die();
}
}
这是 Mob 的脚本。
extends CharacterBody3D
# Minimum speed of the mob in meters per second.
@export var min_speed = 10
# Maximum speed of the mob in meters per second.
@export var max_speed = 18
# Emitted when the player jumped on the mob
signal squashed
func _physics_process(_delta):
move_and_slide()
# This function will be called from the Main scene.
func initialize(start_position, player_position):
# We position the mob by placing it at start_position
# and rotate it towards player_position, so it looks at the player.
look_at_from_position(start_position, player_position, Vector3.UP)
# Rotate this mob randomly within range of -45 and +45 degrees,
# so that it doesn't move directly towards the player.
rotate_y(randf_range(-PI / 4, PI / 4))
# We calculate a random speed (integer)
var random_speed = randi_range(min_speed, max_speed)
# We calculate a forward velocity that represents the speed.
velocity = Vector3.FORWARD * random_speed
# We then rotate the velocity vector based on the mob's Y rotation
# in order to move in the direction the mob is looking.
velocity = velocity.rotated(Vector3.UP, rotation.y)
$AnimationPlayer.speed_scale = random_speed / min_speed
func _on_visible_on_screen_notifier_3d_screen_exited():
queue_free()
func squash():
squashed.emit()
queue_free() # Destroy this node
using Godot;
public partial class Mob : CharacterBody3D
{
// Emitted when the played jumped on the mob.
[Signal]
public delegate void SquashedEventHandler();
// Minimum speed of the mob in meters per second
[Export]
public int MinSpeed { get; set; } = 10;
// Maximum speed of the mob in meters per second
[Export]
public int MaxSpeed { get; set; } = 18;
public override void _PhysicsProcess(double delta)
{
MoveAndSlide();
}
// This function will be called from the Main scene.
public void Initialize(Vector3 startPosition, Vector3 playerPosition)
{
// We position the mob by placing it at startPosition
// and rotate it towards playerPosition, so it looks at the player.
LookAtFromPosition(startPosition, playerPosition, Vector3.Up);
// Rotate this mob randomly within range of -45 and +45 degrees,
// so that it doesn't move directly towards the player.
RotateY((float)GD.RandRange(-Mathf.Pi / 4.0, Mathf.Pi / 4.0));
// We calculate a random speed (integer).
int randomSpeed = GD.RandRange(MinSpeed, MaxSpeed);
// We calculate a forward velocity that represents the speed.
Velocity = Vector3.Forward * randomSpeed;
// We then rotate the velocity vector based on the mob's Y rotation
// in order to move in the direction the mob is looking.
Velocity = Velocity.Rotated(Vector3.Up, Rotation.Y);
GetNode<AnimationPlayer>("AnimationPlayer").SpeedScale = randomSpeed / MinSpeed;
}
public void Squash()
{
EmitSignal(SignalName.Squashed);
QueueFree(); // Destroy this node
}
private void OnVisibilityNotifierScreenExited()
{
QueueFree();
}
}