角色动画
这是最后一课,我们会使用 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
向量的那一行之后添加如下代码。
GDScriptC#
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()
的最后。
GDScriptC#
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
节点,所以我们可以在它们之间复用动画。
这个应用程序是一个开源项目。要报告bug或者做出贡献,请前往该应用程序的源代码仓库:`GitHub repository`_.
然后打开 mob.tscn
,创建一个 AnimationPlayer 子节点并且选中它。点击 动画 > 管理动画 ,然后 新建库 ,你应该看到信息 “将创建全局库”。文本处留白然后点击OK。点击 粘贴 图标(剪贴板)然后它应当出现在窗口上。点击OK来关闭窗口。
Next, make sure that the autoplay button () and the looping arrows (Animation looping) are also turned on in the animation editor in the bottom panel. That’s it; all monsters will now play the float animation.
我们可以根据生物的 random_speed
来更改播放速度。打开 Mob 的脚本,在 initialize()
函数的末尾添加下面这行代码。
GDScriptC#
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 游戏的编码。
恭喜!
In the next part, we’ll quickly recap what you learned and give you some links to keep learning more. But for now, here are the complete player.gd
and mob.gd
so you can check your code against them.
这是 Player 脚本。
GDScriptC#
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 的脚本。
GDScriptC#
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();
}
}