动画

在本节中,我们将学习如何为游戏添加动画,我们将从一些基本的动画开始,但你可以根据本教程中的想法添加更复杂的动画。我们将添加两种动画:让玩家眨眼和让方块在原地轻微晃动。

什么是动画?

动画实际上就是在特定时间间隔播放的一组帧,给人以运动的错觉。可以把它想象成一个视频(视频就是按顺序播放的一系列图像),但帧率要低得多。

例如,要让我们的玩家眨眼,我们需要三个动画帧:

  1. 我们当前的玩家,眼睛睁开
  2. 玩家眼睛稍微闭合
  3. 玩家眼睛完全闭合

如果我们按顺序播放这三帧,你会注意到看起来就像玩家在眨眼。你可以通过打开图像并在图像预览中快速切换它们来试试这个效果。

这里有一些需要注意的事项:

  • 资源需要针对特定的帧率设计 - 对我们来说,我们将使用 250 毫秒,这意味着我们每 250 毫秒播放一个新的动画帧,所以我们每秒有 4 帧
  • 资源之间需要保持一致性 - 想象一下如果我们有两种不同的玩家,它们有不同的资源和不同外观的眼睛,我们需要确保当我们创建上述三帧时它们是一致的,否则两个玩家会以不同的速率眨眼
  • 为大量帧设计资源是一项繁重的工作,所以我们会尽量保持动画简单,只关注关键帧

它将如何工作?

那么这在我们现有的推箱子游戏中将如何工作呢?我们需要:

  1. 修改我们的可渲染组件以允许多个帧 - 我们也可以创建一个新的可渲染组件来处理动画可渲染对象,并保留现有的组件用于静态可渲染对象,但现在把它们放在一起感觉更整洁
  2. 修改玩家实体构造以接受多个帧
  3. 在我们的渲染循环中跟踪时间 - 我们稍后会详细讨论这个问题,所以如果现在不太清楚为什么需要这样做也不用担心
  4. 修改渲染系统,考虑帧数、时间和在给定时间应该渲染的帧

资源

让我们为玩家添加新的资源,它应该是这样的。注意我们创建了一个按顺序命名帧的约定,这不是严格必要的,但它将帮助我们轻松跟踪顺序。

玩家1 玩家2 玩家3

├── resources │ └── images │ ├── box_blue.png │ ├── box_red.png │ ├── box_spot_blue.png │ ├── box_spot_red.png │ ├── floor.png │ ├── player_1.png │ ├── player_2.png │ ├── player_3.png │ └── wall.png

可渲染特性

现在,让我们更新可渲染组件,把他们变成一个路径列表以接收多个帧

我们还要添加两个新函数,用于构建两种类型的可渲染对象:一种是单一路径,另一种是多个路径。这两个函数是关联函数,因为它们与结构体 Renderable 相关联,但它们相当于其他语言中的静态函数,因为它们不操作实例(注意它们没有接收 &self&mut self 作为第一个参数,这意味着我们可以在结构体的上下文中调用它们,而不是在结构体实例中调用)。它们也类似于工厂函数,因为它们封装了实际构建对象之前所需的逻辑和验证。

更多: 了解更多关于关联函数的信息。这里.

#![allow(unused)] fn main() { // components.rs pub struct Renderable { paths: Vec<String>, } impl Renderable { pub fn new_static(path: &str) -> Self { Self { paths: vec![path.to_string()], } } pub fn new_animated(paths: Vec<&str>) -> Self { Self { paths: paths.iter().map(|p| p.to_string()).collect(), } } } }

接下来,我们需要一种方法来判断可渲染对象是动画还是静态的,这将在渲染系统中使用。我们可以将 paths 成员变量设为公共,让渲染系统获取 paths 的长度并根据长度推断,但有一种更符合语言习惯的方式。我们可以为可渲染对象的类型添加一个枚举,并在可渲染对象上添加一个方法来获取该类型。这样,我们将类型的逻辑封装在可渲染对象内部,同时可以保持 paths 私有。你可以将类型的声明放在 components.rs 的任何位置,但最好放在 Renderable 声明的旁边。

#![allow(unused)] fn main() { // components.rs pub enum RenderableKind { Static, Animated, } }

现在让我们添加一个函数,根据内部的 paths 告诉我们可渲染对象的类型。

#![allow(unused)] fn main() { // components.rs pub fn kind(&self) -> RenderableKind { match self.paths.len() { 0 => panic!("invalid renderable"), 1 => RenderableKind::Static, _ => RenderableKind::Animated, } } }

最后,由于我们将 paths 设为私有,因此需要让可渲染对象的使用者能够从列表中获取特定路径。对于静态可渲染对象,这将是第 0 个路径(唯一的一个),而对于动画路径,我们将让渲染系统根据时间决定应该渲染哪个路径。唯一需要注意的地方是,如果请求的帧数超出了我们拥有的范围,我们将通过对长度取模来循环处理。

#![allow(unused)] fn main() { // components.rs pub fn path(&self, path_index: usize) -> String { // If we get asked for a path that is larger than the // number of paths we actually have, we simply mod the index // with the length to get an index that is in range. self.paths[path_index % self.paths.len()].clone() } }

实体创建

接下来,我们将更新玩家实体的创建,以考虑多个路径。请注意,现在我们使用 new_animated 函数来构建可渲染对象

#![allow(unused)] fn main() { // entities.rs pub fn create_box(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_animated(vec![ &format!("/images/box_{}_1.png", colour), &format!("/images/box_{}_2.png", colour), ]), Box { colour }, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable::new_static(&format!("/images/box_spot_{}.png", colour)), BoxSpot { colour }, )) } }

并且让我们更新所有其他部分以使用 new_static 函数——以下是我们如何在墙壁实体创建中实现这一点的示例,请随意将其应用到其他静态实体中。

#![allow(unused)] fn main() { // entities.rs use crate::components::*; use hecs::{Entity, World}; pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_static("/images/wall.png"), Wall {}, Immovable {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable::new_static("/images/floor.png"), )) } // ANCHOR: create_box pub fn create_box(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_animated(vec![ &format!("/images/box_{}_1.png", colour), &format!("/images/box_{}_2.png", colour), ]), Box { colour }, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position, colour: BoxColour) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable::new_static(&format!("/images/box_spot_{}.png", colour)), BoxSpot { colour }, )) } // ANCHOR_END: create_box pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable::new_animated(vec![ "/images/player_1.png", "/images/player_2.png", "/images/player_3.png", ]), Player {}, Movable {}, )) } pub fn create_gameplay(world: &mut World) -> Entity { world.spawn((Gameplay::default(),)) } // ANCHOR: create_time pub fn create_time(world: &mut World) -> Entity { world.spawn((Time::default(),)) } // ANCHOR_END: create_time }

时间

我们还需要另一个组件来记录时间。时间与此有什么关系?它又是如何与帧率联系起来的呢?基本思路是这样的:ggez 控制渲染系统的调用频率,这取决于帧率,而帧率又取决于我们在游戏循环的每次迭代中做了多少工作。由于我们无法控制这一点,在一秒钟内,渲染系统可能会被调用 60 次、57 次,甚至可能只有 30 次。这意味着我们的动画系统不能基于帧率,而需要基于时间。

正因如此,我们需要记录增量时间(delta time),也就是上一次循环和当前循环之间经过的时间。由于增量时间比我们设定的动画帧间隔(我们决定为 250 毫秒)要小得多,因此我们需要累积增量时间,也就是从游戏启动开始到现在经过的总时间。

更多: 了解更多关于增量时间、帧率和游戏循环的详细介绍 这里, here or here .

现在,我们为时间添加一个资源。这并不适合放入组件模型中,因为时间只是一些需要维护的全局状态。

#![allow(unused)] fn main() { // components.rs #[derive(Default)] pub struct Time { pub delta: Duration, } }

现在,让我们在主循环中更新时间。幸运的是,ggez 提供了一个函数来获取增量时间,所以我们只需要累积它即可。

#![allow(unused)] fn main() { // main.rs fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { systems::input::run_input(&self.world, context); } // Run gameplay state { systems::gameplay::run_gameplay_state(&self.world); } // Get and update time resource { let mut query = self.world.query::<&mut crate::components::Time>(); let time = query.iter().next().unwrap().1; time.delta += context.time.delta(); } Ok(()) } }

渲染系统

现在,我们来更新渲染系统。我们将从可渲染对象中获取类型,如果是静态的,我们直接使用第一帧;否则,我们根据增量时间来确定要使用哪一帧。

首先,我们添加一个函数来封装获取正确图像的逻辑。

#![allow(unused)] fn main() { // rendering.rs pub fn get_image(context: &mut Context, renderable: &Renderable, delta: Duration) -> Image { let path_index = match renderable.kind() { RenderableKind::Static => { // We only have one image, so we just return that 0 } RenderableKind::Animated => { // If we have multiple, we want to select the right one based on the delta time. // First we get the delta in milliseconds, we % by 1000 to get the milliseconds // only and finally we divide by 250 to get a number between 0 and 4. If it's 4 // we technically are on the next iteration of the loop (or on 0), but we will let // the renderable handle this logic of wrapping frames. ((delta.as_millis() % 1000) / 250) as usize } }; let image_path = renderable.path(path_index); Image::from_path(context, image_path).unwrap() } }

最后,我们在 run 函数中使用新的 get_image 函数(我们还需要将时间添加到 SystemData 定义中,并添加一些导入,但基本上就是这样了)。

#![allow(unused)] fn main() { // rendering.rs pub fn run_rendering(world: &World, context: &mut Context) { // Clearing the screen (this gives us the background colour) let mut canvas = graphics::Canvas::from_frame(context, graphics::Color::from([0.95, 0.95, 0.95, 1.0])); // Get time let mut query = world.query::<&Time>(); let time = query.iter().next().unwrap().1; // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut query = world.query::<(&Position, &Renderable)>(); let mut rendering_data: Vec<(Entity, (&Position, &Renderable))> = query.into_iter().collect(); rendering_data.sort_by_key(|&k| k.1 .0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (_, (position, renderable)) in rendering_data.iter() { // Load the image let image = get_image(context, renderable, time.delta); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); canvas.draw(&image, draw_params); } // Render any text let mut query = world.query::<&Gameplay>(); let gameplay = query.iter().next().unwrap().1; draw_text(&mut canvas, &gameplay.state.to_string(), 525.0, 80.0); draw_text(&mut canvas, &gameplay.moves_count.to_string(), 525.0, 100.0); // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } }

箱体动画

现在我们已经学会了如何实现这一点,接下来我们将其扩展到让箱子也实现动画效果。我们只需要添加新的资源并调整实体创建部分,其他部分应该就能正常工作了。以下是我使用的资源,你可以随意复用或创建新的资源!

Box red 1 Box red 2 Box blue 1 Box blue 2

总结一下

这一部分内容比较长,但希望你喜欢!以下是游戏现在应该呈现的效果。

Sokoban animations

代码链接: 在这个例子中你可以看到完整的代码 here.