第一章: 开始编写游戏前
欢迎来到 《使用Rust编写推箱子游戏教程》! 在开始动手编写游戏前,我们需要先了解下:
推箱子是个啥样的游戏嘞?
没玩过推箱子游戏?想当年用诺基亚黑白屏手机的时候就有这款游戏了。你可以下载一个玩一下或者点这里看下维基百科的介绍。本教程就是教大家怎么使用Rust和现有的游戏引擎、素材,编写一个可以玩的推箱子游戏。
谁编写了本教程呢?
本教程是由@oliviff 主笔编写,另外还得到了很多优秀贡献者的支持,感谢您们的辛勤付出(排名不分先后):
为什么要使用Rust编写推箱子游戏呢?
我是2019年3月份开始学习Rust的,在编写本教程前我就使用Rust开发过游戏。在学习和使用Rust的过程中我还写了一些博客,感觉从Rust游戏开发中我学到了很多,于是乎我就有个想法:这么好的东西得分享给大家啊,让大家都来体验下啊,独乐乐不如众乐乐!然后就有了本教程。
那是不是得先去学习下Rust呢?
不需要。本教程会手把手一步一步教你怎么使用Rust编写游戏,也会对一些Rust的语法进行一些必要的解释。对于一些知识点我们也会提供更详细的介绍链接供您学习参考。当然本教程主要是通过编写一个有趣的游戏顺便对Rust语言进行简单的介绍,所以有些Rust的知识点我们可能不会也没必要过多的深入。
文本样式约定
我们使用下面这种样式的文本链接对Rust或者游戏开发等的知识点的扩展信息。
MORE: 点这里查看更多.
我们使用下面这种样式的文本链接本章内容相关的程序代码。
CODELINK: 点这里查看示例完整代码.
学习资源
如果在学习过程中你需要寻求帮助或者有问题需要找人帮忙解答,可以看下这些地方:
另外Rust背后还有一群很优秀的开发者组成的社区,所以如果有问题也可以寻求社区帮助。
就先介绍到这里吧,接下来让我们开始编写第一个Rust游戏(准确来说,是我的第二个,但希望这是你们的第一个😉)
Made with 🦀 and 🧡 by @oliviff 由FusionZhu翻译
项目搭建
建议使用rustup安装管理Rust。安装好Rust后可以在命令行输入以下俩条命令,检查确认是否安装成功:
$ rustc --version
rustc 1.40.0
$ cargo --version
cargo 1.40.0
输出的版本信息未必都是这样的,但建议使用比较新的Rust版本。
创建项目
Cargo是Rust的包管理工具,可以使用它创建我们的游戏项目。首先切换到游戏项目存储路径,然后再输入以下命令:
$ cargo init rust-sokoban
命令执行成功后,会在当前目录下创建一个名称为rust-sokoban
的文件夹。文件夹内部是这个样子的:
├── src
│ └── main.rs
└── Cargo.toml
切换到文件夹rust-sokoban
并运行命令 cargo run
,你会看到类似下面的输出信息:
$ cargo run
Compiling rust-sokoban v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.30s
Running `../rust-sokoban/target/debug/rust-sokoban`
Hello, world!
添加游戏开发依赖
接下来让我们一起把默认生成的项目修改成一个游戏项目! 我们使用当前最受欢迎的2D游戏引擎之一的ggez
还记得我们刚才在项目目录里看到的Cargo.toml
文件吧?这个文件是用来管理项目依赖的,所以需要把我们需要使用到的crate
添加到这个文件中。就像这样添加 ggez 依赖:
[dependencies]
ggez = "0.9.3"
MORE: 更多关于Cargo.toml的信息可以看 这里.
接下来再次执行cargo run
.这次执行的会长一点,因为需要从crates.io下载我们配置的依赖库并编译链接到我们库中。
cargo run
Updating crates.io index
Downloaded ....
....
Compiling ....
....
Finished dev [unoptimized + debuginfo] target(s) in 2m 15s
Running `.../rust-sokoban/target/debug/rust-sokoban`
Hello, world!
NOTE: 如果你是使用的Ubuntu操作系统,在执行命令的时候可能会报错,如果报错信息有提到
alsa
和libudev
可以通过执行下面的命令安装解决:sudo apt-get install libudev-dev libasound2-dev
.
接下来我们在main.rs文件中使用ggez
创建一个窗口。只是创建一个空的窗口,代码比较简单:
use ggez::{conf, event, Context, GameResult}; use std::path; // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game {} // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // TODO: update game logic here Ok(()) } fn draw(&mut self, _context: &mut Context) -> GameResult { // TODO: update draw here Ok(()) } } pub fn main() -> GameResult { // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game {}; // Run the main event loop event::run(context, event_loop, game) }
可以把代码复制到main.rs文件中,并再次执行cargo run
,你会看到:
基本概念和语法
现在我们有了个窗口,我们创建了个窗口耶!接下来我们一起分析下代码并解释下使用到的Rust概念和语法。
引入
您应该在其它编程语言中也接触过这个概念,就是把我们需要用到的依赖包(或crate)里的类型和命名空间引入到当前的代码作用域中。在Rust中,使用use
实现引入功能:
#![allow(unused)] fn main() { // 从ggez命名空间引入conf, event, Context 和 GameResult use ggez::{conf, event, Context, GameResult}; }
结构体声明
#![allow(unused)] fn main() { // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game {} }
MORE: 查看更多结构体相关信息可以点 这里.
实现特征
特征类似其它语言中的接口,就是用来表示具备某些行为的特定类型。在我们的示例中需要结构体Game实现EventHandler特征。
#![allow(unused)] fn main() { // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // TODO: update game logic here Ok(()) } fn draw(&mut self, _context: &mut Context) -> GameResult { // TODO: update draw here Ok(()) } } }
MORE: 想更深入的了解特征可以点 这里.
函数
我们还需要学习下怎么使用Rust编写函数:
#![allow(unused)] fn main() { fn update(&mut self, _context: &mut Context) -> GameResult { // TODO: update game logic here Ok(()) } }
你可能会疑惑这里的self
是几个意思呢?这里使用self
代表函数update
是属于结构体的实例化对象而不是静态的。
MORE: 想深入了解函数可以点 这里.
可变语法
你可能更疑惑&mut self
这里的&mut
是做什么的? 这个主要用来声明一个对象(比如这里的self
)是否可以被改变的。再来看个例子:
#![allow(unused)] fn main() { let a = 10; // a是不可变的,因为没有使用`mut`声明它是可变的 let mut b = 20; // b是可变的,因为使用了`mut`声明了它是可变的 }
再回头看update
函数,我们使用了&mut
声明self是实例对象的可变引用。有没有点感觉了, 要不我们再看一个例子:
#![allow(unused)] fn main() { // 一个简单的结构体X struct X { num: u32 } //结构体X的实现代码块 impl X { fn a(&self) { self.num = 5 } // 在函数a中不能修改`self`,这会编译失败的,因为是使用的`&self` fn b(&mut self) { self.num = 5 } // 在函数b中可以修改`self`,因为使用的是`&mut self` } }
MORE: 想更多的了解
可变性
可以看 这里 (虽然是使用的Java作为演示语言讲解的,但对于理解可变性还是很有帮助地), 另外还可以看 这里.
对代码和Rust语法的简单介绍就先到这里,让我们继续前进吧,下一节见!
CODELINK: 要获取本节的完整代码可以点 这里.
实体构建系统
在本章节中我们将更详细的介绍下推箱子游戏并探讨下该怎么构建我们的游戏
推箱子游戏
如果你还没玩过推箱子游戏,可以先看下这张推箱子游戏的动态图片:
游戏中有墙有箱子,玩家的目标是把箱子推到它们的位置上。
ECS
ECS
(实体构建系统)是一种遵循组合优于继承的构建游戏的模式. 像多少Rust游戏一样,我们编写的推箱子游戏也会大量使用ECS
,所以我们有必要先花点时间熟悉下ECS
:
- 组件(Components) - 组件只包含数据不包含行为,比如:位置组件、可渲染组件和运动组件。
- 实体(Entities) - 实体是由多个组件组成的,比如玩家,可能是由位置组件、可渲染组件、动作组件组合而成的,而地板可能只需要位置组件和可渲染组件,因为它不会动。也可以说实体几乎就是包含一个或多个具有唯一标示信息的组件的容器。
- 系统(Systems) - 系统使用实体和组件并包含基于数据的行为和逻辑。比如渲染系统:它可以一个一个的处理并绘制可渲染实体。就像我们上面提到的组件本身不包含行为,而是通过系统根据数据创建行为。
如果现在觉得还不是很理解ECS
,也没有关系,我们下面章节还会介绍一些结合推箱子游戏的实例。
推箱子游戏结构
根据我们对推箱子游戏的了解,要编写一个这样的游戏,起码要有:墙、玩家、地板、箱子还有方块斑点这些实体。
接下来我们需要确认下怎么创建实体,也就是需要什么样的组件。首先,我们需要跟踪地图上所有的东西,所以我们需要一些位置组件。其次,某些实体可以移动,比如:玩家和箱子。所以我们需要一些动作组件。最后,我们还需要绘制实体,所以还需要一些渲染组件。
按照这个思路我们先出一版:
- 玩家实体: 有
位置组件
、可渲染组件
、运动组件
组成 - 墙实体: 有
位置组件
和可渲染组件
组成 - 地板实体: 有
位置组件
和可渲染组件
组成 - 箱子实体: 有
位置组件
、可渲染组件
和运动组件
组成 - 方框斑点组件: 有
位置组件
和运动组件
组成
第一次接触ECS
是有点难于理解,如果不理解这些也没关系,可接着往下面看。
Specs
最后我们需要一个提供ECS
的crate
,虽然这样的库有一大把,但在本教程中我们使用 specs ,需要在Cargo.toml
文件中配置specs依赖:
[dependencies]
ggez = "0.9.3"
hecs = "0.10.5"
加油!接下来我们就开始编写组件和实体了!是不是很期待?
CODELINK: 可以点 这里获取本章节完整代码.
组件和实体
在本节中,我们将创建组件,学习如何创建实体,并注册所有内容以确保 specs
正常工作。
定义组件
我们先从定义组件开始。之前我们讨论了 Position
(位置组件)、Renderable
(可渲染组件)和 Movement
(动作组件),但暂时我们会跳过动作组件。我们还需要一些组件来标识每个实体。例如,我们需要一个 Wall
(墙)组件,通过它来标识一个实体是墙。
希望这很直观:位置组件存储 x、y 和 z 坐标,用于告诉我们某物在地图上的位置;可渲染组件会接收一个字符串路径,指向一张可以渲染的图片。所有其他组件都是 标记型组件,暂时不包含任何数据。
#![allow(unused)] fn main() { #[allow(dead_code)] pub struct Position { x: u8, y: u8, z: u8, } #[allow(dead_code)] pub struct Renderable { path: String, } pub struct Wall {} pub struct Player {} pub struct Box {} pub struct BoxSpot {} }
在熟悉的 Rust 代码中,我们使用了一些新语法。我们使用了一个强大的 Rust 功能,称为“过程宏”(Procedural Macros
),例如 #[storage(VecStorage)]
。这种宏本质上是一些函数,在编译时会处理某些语法并生成新的语法。
更多内容: 想了解更多关于过程宏的信息,请参阅 这里。
创建实体
实体只是一个与一组组件相关联的数字标识符。因此,我们创建实体的方法是简单地指定它们包含哪些组件。
现在,创建实体的代码如下所示:
#![allow(unused)] fn main() { pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/wall.png".to_string(), }, Wall {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable { path: "/images/floor.png".to_string(), }, )) } pub fn create_box(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/box.png".to_string(), }, Box {}, )) } pub fn create_box_spot(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable { path: "/images/box_spot.png".to_string(), }, BoxSpot {}, )) } pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/player.png".to_string(), }, Player {}, )) } }
资源
您可能已经注意到,我们在上面的实体创建中引用了要使用的资源。您可以自由创建自己的资源,或者下载我们提供的资源(右键点击图片并选择“另存为”)。
让我们将这些图片添加到项目中。创建一个 resources
文件夹,用于存放所有资源。目前,这些资源仅包括图片,但将来我们可能会添加配置文件或音频文件(继续阅读,您将在 第三章第三节 学习如何播放声音)。在 resources
文件夹下再创建一个 images
文件夹,将我们的 PNG 图片放入其中。您也可以使用不同的文件夹结构,但在本节后续部分使用图片时,请确保路径正确。
项目的目录结构如下所示:
├── resources
│ └── images
│ ├── box.png
│ ├── box_spot.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ └── main.rs
└── Cargo.toml
创建游戏世界
最后,让我们将所有内容整合在一起。我们需要创建一个 specs::World
对象,将其添加到我们的 Game
结构中,并在主函数中首先初始化它。以下是完整代码,现在运行时仍会显示一个空白窗口,但我们在设置游戏组件和实体方面已经取得了巨大进展!接下来,我们将进入渲染部分,最终在屏幕上看到内容!
pub fn main() -> GameResult { let world = World::new(); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) }
注意:运行时,控制台可能会报告一些关于未使用导入或字段的警告,不用担心这些问题,我们将在后续章节中修复它们。
CODELINK: 您可以在 这里 查看本节完整代码。
渲染系统
现在是时候实现我们的第一个系统——渲染系统了。这个系统将负责在屏幕上绘制所有的实体。
渲染系统设置
首先,我们从一个空的实现开始,如下所示:
#![allow(unused)] fn main() { pub fn run_rendering(world: &World, context: &mut Context) { // TODO 添加实现 } }
最后,让我们在绘制循环中运行渲染系统。这意味着每次游戏更新时,我们都会渲染所有实体的最新状态。
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { run_rendering(&self.world, context); } Ok(()) } } }
现在运行游戏应该可以编译,但可能还不会有任何效果,因为我们尚未填充渲染系统的实现,也没有创建任何实体。
渲染系统实现
注意: 我们将在这里添加 glam 作为依赖项,这是一个简单快速的 3D 库,可以提供一些性能改进。
[dependencies]
ggez = "0.9.3"
hecs = "0.10.5"
以下是渲染系统的实现。它完成以下几个任务:
- 清除屏幕(确保我们不会保留前一帧渲染的状态)
- 获取所有具有可渲染组件的实体并按 z 轴排序(这样我们可以确保正确的叠加顺序,例如玩家应该在地板之上,否则我们看不到玩家)
- 遍历排序后的实体并将它们作为图像渲染
- 最后,呈现到屏幕上
#![allow(unused)] fn main() { 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 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 = Image::from_path(context, renderable.path.clone()).unwrap(); 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); } // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } }
添加一些测试实体
让我们创建一些测试实体以确保工作正常。
#![allow(unused)] fn main() { // Initialize the level pub fn initialize_level(world: &mut World) { create_player( world, Position { x: 0, y: 0, z: 0, // we will get the z from the factory functions }, ); create_wall( world, Position { x: 1, y: 0, z: 0, // we will get the z from the factory functions }, ); create_box( world, Position { x: 2, y: 0, z: 0, // we will get the z from the factory functions }, ); } }
最后,让我们将所有内容组合在一起并运行。你应该会看到类似这样的效果!这非常令人兴奋,现在我们有了一个正式的渲染系统,我们终于可以在屏幕上看到一些东西了。接下来,我们将开始处理游戏玩法,使其真正像一个游戏!
以下是最终代码。
注意: 请注意,这是渲染的一个非常基本的实现,随着实体数量的增加,性能可能不足够好。一个更高级的渲染实现使用批量渲染,可以在第 3 章 - 批量渲染中找到。
/* ANCHOR: all */ // Rust sokoban // main.rs use ggez::{ conf, event, graphics::{self, DrawParam, Image}, Context, GameResult, }; use glam::Vec2; use hecs::{Entity, World}; use std::path; const TILE_WIDTH: f32 = 32.0; // ANCHOR: components pub struct Position { x: u8, y: u8, z: u8, } pub struct Renderable { path: String, } pub struct Wall {} pub struct Player {} pub struct Box {} pub struct BoxSpot {} // ANCHOR_END: components // ANCHOR: game // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // ANCHOR_END: game // ANCHOR: init // Initialize the level pub fn initialize_level(world: &mut World) { create_player( world, Position { x: 0, y: 0, z: 0, // we will get the z from the factory functions }, ); create_wall( world, Position { x: 1, y: 0, z: 0, // we will get the z from the factory functions }, ); create_box( world, Position { x: 2, y: 0, z: 0, // we will get the z from the factory functions }, ); } // ANCHOR_END: init // ANCHOR: handler impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { run_rendering(&self.world, context); } Ok(()) } } // ANCHOR_END: handler // ANCHOR: entities pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/wall.png".to_string(), }, Wall {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable { path: "/images/floor.png".to_string(), }, )) } pub fn create_box(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/box.png".to_string(), }, Box {}, )) } pub fn create_box_spot(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable { path: "/images/box_spot.png".to_string(), }, BoxSpot {}, )) } pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/player.png".to_string(), }, Player {}, )) } // ANCHOR_END: entities // ANCHOR: rendering_system 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 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 = Image::from_path(context, renderable.path.clone()).unwrap(); 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); } // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } // ANCHOR_END: rendering_system // ANCHOR: main pub fn main() -> GameResult { let world = World::new(); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) } // ANCHOR_END: main /* ANCHOR_END: all */
CODELINK: 你可以在 这里 查看本示例的完整代码。
第二章: 实现基本功能
你好棒棒哦,已经读到第2章啦!在这一章中我们将实现一些游戏的基本功能比如:加载地图,让角色动起来等,总之完成了这一章,我们的程序就有点游戏的意思了.激不激动,让我们继续前进,前进,前进进!
地图加载
上一章我们创建了一些实体来测试我们的渲染系统,但现在是时候渲染一个正式的地图了。在本节中,我们将创建一个基于文本的地图配置并加载它。
地图配置
第一步,让我们尝试基于如下所示的二维地图加载一个关卡。
#![allow(unused)] fn main() { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; 其中: . 是空白位置 W 是墙 P 是玩家 B 是箱子 S 是箱子放置点 N 是空:用于地图的外边缘 }
最终我们可以从文件中加载,但为了简单起见,现在我们先用代码中的常量。
以下是加载地图的实现。
#![allow(unused)] fn main() { // Initialize the level// Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => { create_floor(world, position); } "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } }
这里最有趣的 Rust 概念可能是 match
。我们在这里使用了模式匹配的基本功能,仅仅是匹配地图配置中每个标记的值,但我们可以进行更高级的条件或类型模式匹配。
更多: 阅读更多关于模式匹配的信息 这里。
现在运行游戏,看看我们的地图是什么样子。
以下是最终代码。
/* ANCHOR: all */ // Rust sokoban // main.rs use ggez::{ conf, event, graphics::{self, DrawParam, Image}, Context, GameResult, }; use glam::Vec2; use hecs::{Entity, World}; use std::path; const TILE_WIDTH: f32 = 32.0; // ANCHOR: components #[derive(Clone, Copy)] pub struct Position { x: u8, y: u8, z: u8, } pub struct Renderable { path: String, } pub struct Wall {} pub struct Player {} pub struct Box {} pub struct BoxSpot {} // ANCHOR_END: components // ANCHOR: game // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // ANCHOR_END: game // ANCHOR: init // Initialize the level// Initialize the level pub fn initialize_level(world: &mut World) { // ANCHOR: map const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; // ANCHOR_END: map load_map(world, MAP.to_string()); } pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => { create_floor(world, position); } "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } // ANCHOR_END: init // ANCHOR: handler impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { run_rendering(&self.world, context); } Ok(()) } } // ANCHOR_END: handler // ANCHOR: entities pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/wall.png".to_string(), }, Wall {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable { path: "/images/floor.png".to_string(), }, )) } pub fn create_box(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/box.png".to_string(), }, Box {}, )) } pub fn create_box_spot(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable { path: "/images/box_spot.png".to_string(), }, BoxSpot {}, )) } pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/player.png".to_string(), }, Player {}, )) } // ANCHOR_END: entities // ANCHOR: rendering_system 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 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 = Image::from_path(context, renderable.path.clone()).unwrap(); 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); } // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } // ANCHOR_END: rendering_system // ANCHOR: main pub fn main() -> GameResult { let mut world = World::new(); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) } // ANCHOR_END: main /* ANCHOR_END: all */
CODELINK: 你可以在 这里 查看本示例的完整代码。
移动玩家
如果我们不能移动玩家,那就不能算是游戏,对吧?在本节中,我们将学习如何获取输入事件。
输入事件
让玩家移动的第一步是开始监听输入事件。如果我们快速查看 ggez 输入示例,可以看到我们可以使用 is_key_pressed
检查某个键是否被按下。
让我们从一个非常基本的输入系统实现开始,在这里我们只是检查某个键是否被按下并打印到控制台。
#![allow(unused)] fn main() { #[allow(dead_code)] fn run_input_print(_world: &World, context: &mut Context) { if context.keyboard.is_key_pressed(KeyCode::Up) { println!("UP"); } if context.keyboard.is_key_pressed(KeyCode::Down) { println!("DOWN"); } if context.keyboard.is_key_pressed(KeyCode::Left) { println!("LEFT"); } if context.keyboard.is_key_pressed(KeyCode::Right) { println!("RIGHT"); } } }
然后,我们将这段代码添加到 Game 的 event::EventHandler
实现块中:
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { run_input(&self.world, context); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { run_rendering(&self.world, context); } Ok(()) } } }
如果我们运行它,应该会在控制台中看到打印的行。
LEFT
LEFT
RIGHT
UP
DOWN
LEFT
输入系统
现在让我们实现最终的输入系统。
我们已经有了一种方法来检查某个键是否被按下,现在我们需要实现移动玩家的逻辑。我们希望实现的逻辑是:
- 如果按下 UP 键,我们将玩家在 y 轴上向上移动一个位置
- 如果按下 DOWN 键,我们将玩家在 y 轴上向下移动一个位置
- 如果按下 LEFT 键,我们将玩家在 x 轴上向左移动一个位置
- 如果按下 RIGHT 键,我们将玩家在 x 轴上向右移动一个位置
#![allow(unused)] fn main() { #[allow(dead_code)] fn input_system_duplicate(world: &World, context: &mut Context) { for (_, (position, _player)) in world.query::<(&mut Position, &Player)>().iter() { if context.keyboard.is_key_pressed(KeyCode::Up) { position.y -= 1; } if context.keyboard.is_key_pressed(KeyCode::Down) { position.y += 1; } if context.keyboard.is_key_pressed(KeyCode::Left) { position.x -= 1; } if context.keyboard.is_key_pressed(KeyCode::Right) { position.x += 1; } } } }
输入系统非常简单,它获取所有玩家和位置(我们应该只有一个玩家,但这段代码不需要关心这一点,它理论上可以工作于我们希望用相同输入控制多个玩家的情况)。然后,对于每个玩家和位置组合,它将获取第一个按下的键并从输入队列中移除它。接着,它会计算所需的变换——例如,如果按下上键,我们希望向上移动一个格子,依此类推,并应用这个位置更新。
非常酷!这就是它的效果。注意我们可以穿过墙和箱子。在下一节中,当我们添加可移动组件时,我们会修复这个问题。
但你可能注意到一个问题,单次按键会触发多次移动。让我们在下一节中修复这个问题。
处理多次按键
问题在于我们在一秒内多次调用输入系统,这意味着按住一个键一秒钟会触发多次移动。作为玩家,这不是一个很好的体验,因为你无法很好地控制移动,很容易陷入一种情况——箱子卡在墙边,无法拉回。
我们有哪些选项来解决这个问题?我们可以记住上一个帧是否按下了键,如果是,我们跳过它。这需要存储上一帧的状态,并在当前帧中与之比较以决定是否移动,这完全可行。幸运的是,ggez 在他们的键盘 API 中添加了这个功能,你可以调用 is_key_just_pressed
,它会自动检查当前状态。让我们试试,它看起来像这样:
#![allow(unused)] fn main() { fn run_input(world: &World, context: &mut Context) { for (_, (position, _player)) in world.query::<(&mut Position, &Player)>().iter() { if context.keyboard.is_key_just_pressed(KeyCode::Up) { position.y -= 1; } if context.keyboard.is_key_just_pressed(KeyCode::Down) { position.y += 1; } if context.keyboard.is_key_just_pressed(KeyCode::Left) { position.x -= 1; } if context.keyboard.is_key_just_pressed(KeyCode::Right) { position.x += 1; } } } }
现在一切都按预期工作了!
CODELINK: 你可以在 这里 查看本示例的完整代码。
推动箱子
在上一章中,我们让玩家可以移动,但他可以穿过墙壁和箱子,并没有真正与环境交互。在本节中,我们将为玩家的移动添加一些更智能的逻辑。
移动组件
首先,我们需要让代码稍微更通用一些。如果你还记得上一章,我们是通过操作玩家来决定如何移动他们的,但我们也需要移动箱子。此外,将来我们可能会引入其他可移动类型的对象,因此我们需要考虑到这一点。按照真正的 ECS 精神,我们将使用标记组件来区分哪些实体是可移动的,哪些不是。例如,玩家和箱子是可移动的,而墙是不可移动的。箱子放置点在这里无关紧要,因为它们不会移动,但它们也不应该影响玩家或箱子的移动,因此箱子放置点不会具有这些组件中的任何一个。
以下是我们的两个新组件:
#![allow(unused)] fn main() { pub struct Movable; pub struct Immovable; }
接下来,我们将:
- 为玩家和箱子添加
with(Movable)
- 为墙添加
with(Immovable)
- 对地板和箱子放置点不做任何操作(如前所述,它们不应成为我们的移动/碰撞系统的一部分,因为它们对移动没有影响)
#![allow(unused)] fn main() { pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/wall.png".to_string(), }, Wall {}, Immovable {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable { path: "/images/floor.png".to_string(), }, )) } pub fn create_box(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/box.png".to_string(), }, Box {}, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable { path: "/images/box_spot.png".to_string(), }, BoxSpot {}, )) } pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/player.png".to_string(), }, Player {}, Movable {}, )) } }
移动需求
现在让我们思考一些示例来说明移动的需求。这将帮助我们理解如何修改输入系统的实现以正确使用 Movable
和 Immovable
。
场景:
(player, floor)
并按下RIGHT
-> 玩家应该向右移动(player, wall)
并按下RIGHT
-> 玩家不应向右移动(player, box, floor)
并按下RIGHT
-> 玩家应该向右移动,箱子也应该向右移动(player, box, wall)
并按下RIGHT
-> 没有任何东西应该移动(player, box, box, floor)
并按下RIGHT
-> 玩家、箱子1 和箱子2 应该都向右移动一格(player, box, box, wall)
并按下RIGHT
-> 没有任何东西应该移动
基于这些场景,我们可以做出以下观察:
- 碰撞/移动检测应该一次性处理所有涉及的对象——例如,对于场景 6,如果我们逐个处理,每次处理一个对象,我们会先移动玩家,再移动第一个箱子,当我们处理第二个箱子时发现无法移动,此时需要回滚所有的移动操作,这是不可行的。因此,对于每个输入,我们必须找出所有涉及的对象,并整体判断该动作是否可行。
- 一条包含空位的可移动链可以移动(空位在此表示既非可移动也非不可移动的东西)
- 一条包含不可移动位置的可移动链不能移动
- 尽管所有示例都是向右移动,这些规则应该可以推广到任何方向,按键仅仅影响我们如何找到链条
因此,基于这些规则,让我们开始实现这个逻辑。以下是我们需要的逻辑模块的一些初步想法:
- 找到所有可移动和不可移动的实体 - 这样我们可以判断它们是否受到移动的影响
- 根据按键确定移动方向 - 我们在上一节已经大致解决了这个问题,基本上是一些基于按键枚举的 +1/-1 操作
- 遍历从玩家到地图边缘的所有位置,根据方向确定轴线——例如,如果按下右键,我们需要从
player.x
遍历到map_width
,如果按下上键,我们需要从0
遍历到player.y
- 对于序列中的每个格子 我们需要:
- 如果格子是可移动的,继续并记住这个格子
- 如果格子不可移动,停止并不移动任何东西
- 如果格子既不可移动也不可移动,移动我们记住的所有格子
以下是输入系统的新实现,有点长,但希望能理解。
#![allow(unused)] fn main() { fn run_input(world: &World, context: &mut Context) { let mut to_move: Vec<(Entity, KeyCode)> = Vec::new(); // get all the movables and immovables let mov: HashMap<(u8, u8), Entity> = world .query::<(&Position, &Movable)>() .iter() .map(|t| ((t.1 .0.x, t.1 .0.y), t.0)) .collect::<HashMap<_, _>>(); let immov: HashMap<(u8, u8), Entity> = world .query::<(&Position, &Immovable)>() .iter() .map(|t| ((t.1 .0.x, t.1 .0.y), t.0)) .collect::<HashMap<_, _>>(); for (_, (position, _player)) in world.query::<(&mut Position, &Player)>().iter() { if context.keyboard.is_key_repeated() { continue; } // Now iterate through current position to the end of the map // on the correct axis and check what needs to move. let key = if context.keyboard.is_key_pressed(KeyCode::Up) { KeyCode::Up } else if context.keyboard.is_key_pressed(KeyCode::Down) { KeyCode::Down } else if context.keyboard.is_key_pressed(KeyCode::Left) { KeyCode::Left } else if context.keyboard.is_key_pressed(KeyCode::Right) { KeyCode::Right } else { continue; }; let (start, end, is_x) = match key { KeyCode::Up => (position.y, 0, false), KeyCode::Down => (position.y, MAP_HEIGHT - 1, false), KeyCode::Left => (position.x, 0, true), KeyCode::Right => (position.x, MAP_WIDTH - 1, true), _ => continue, }; let range = if start < end { (start..=end).collect::<Vec<_>>() } else { (end..=start).rev().collect::<Vec<_>>() }; for x_or_y in range { let pos = if is_x { (x_or_y, position.y) } else { (position.x, x_or_y) }; // find a movable // if it exists, we try to move it and continue // if it doesn't exist, we continue and try to find an immovable instead match mov.get(&pos) { Some(entity) => to_move.push((*entity, key)), None => { // find an immovable // if it exists, we need to stop and not move anything // if it doesn't exist, we stop because we found a gap match immov.get(&pos) { Some(_id) => to_move.clear(), None => break, } } } } } // Now actually move what needs to be moved for (entity, key) in to_move { let mut position = world.get::<&mut Position>(entity).unwrap(); match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } }
现在运行代码,你会发现它真的有效了!我们不能再穿过墙壁,可以推动箱子,当箱子碰到墙时会停下。
以下是完整代码。
/* ANCHOR: all */ // Rust sokoban // main.rs use ggez::{ conf, event, graphics::{self, DrawParam, Image}, input::keyboard::KeyCode, Context, GameResult, }; use glam::Vec2; use hecs::{Entity, World}; use std::collections::HashMap; use std::path; const TILE_WIDTH: f32 = 32.0; const MAP_WIDTH: u8 = 8; const MAP_HEIGHT: u8 = 9; // ANCHOR: components #[derive(Clone, Copy, Eq, Hash, PartialEq)] pub struct Position { x: u8, y: u8, z: u8, } pub struct Renderable { path: String, } pub struct Wall {} pub struct Player {} pub struct Box {} pub struct BoxSpot {} // ANCHOR: components_movement pub struct Movable; pub struct Immovable; // ANCHOR_END: components_movement // ANCHOR_END: components // ANCHOR: game // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // ANCHOR_END: game // ANCHOR: init // Initialize the level// Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => { create_floor(world, position); } "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } // ANCHOR_END: init // ANCHOR: handler impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { run_input(&self.world, context); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { run_rendering(&self.world, context); } Ok(()) } } // ANCHOR_END: handler // ANCHOR: entities pub fn create_wall(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/wall.png".to_string(), }, Wall {}, Immovable {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable { path: "/images/floor.png".to_string(), }, )) } pub fn create_box(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/box.png".to_string(), }, Box {}, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable { path: "/images/box_spot.png".to_string(), }, BoxSpot {}, )) } pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/player.png".to_string(), }, Player {}, Movable {}, )) } // ANCHOR_END: entities // ANCHOR: rendering_system 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 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 = Image::from_path(context, renderable.path.clone()).unwrap(); 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); } // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } // ANCHOR_END: rendering_system // ANCHOR: input_system fn run_input(world: &World, context: &mut Context) { let mut to_move: Vec<(Entity, KeyCode)> = Vec::new(); // get all the movables and immovables let mov: HashMap<(u8, u8), Entity> = world .query::<(&Position, &Movable)>() .iter() .map(|t| ((t.1 .0.x, t.1 .0.y), t.0)) .collect::<HashMap<_, _>>(); let immov: HashMap<(u8, u8), Entity> = world .query::<(&Position, &Immovable)>() .iter() .map(|t| ((t.1 .0.x, t.1 .0.y), t.0)) .collect::<HashMap<_, _>>(); for (_, (position, _player)) in world.query::<(&mut Position, &Player)>().iter() { if context.keyboard.is_key_repeated() { continue; } // Now iterate through current position to the end of the map // on the correct axis and check what needs to move. let key = if context.keyboard.is_key_pressed(KeyCode::Up) { KeyCode::Up } else if context.keyboard.is_key_pressed(KeyCode::Down) { KeyCode::Down } else if context.keyboard.is_key_pressed(KeyCode::Left) { KeyCode::Left } else if context.keyboard.is_key_pressed(KeyCode::Right) { KeyCode::Right } else { continue; }; let (start, end, is_x) = match key { KeyCode::Up => (position.y, 0, false), KeyCode::Down => (position.y, MAP_HEIGHT - 1, false), KeyCode::Left => (position.x, 0, true), KeyCode::Right => (position.x, MAP_WIDTH - 1, true), _ => continue, }; let range = if start < end { (start..=end).collect::<Vec<_>>() } else { (end..=start).rev().collect::<Vec<_>>() }; for x_or_y in range { let pos = if is_x { (x_or_y, position.y) } else { (position.x, x_or_y) }; // find a movable // if it exists, we try to move it and continue // if it doesn't exist, we continue and try to find an immovable instead match mov.get(&pos) { Some(entity) => to_move.push((*entity, key)), None => { // find an immovable // if it exists, we need to stop and not move anything // if it doesn't exist, we stop because we found a gap match immov.get(&pos) { Some(_id) => to_move.clear(), None => break, } } } } } // Now actually move what needs to be moved for (entity, key) in to_move { let mut position = world.get::<&mut Position>(entity).unwrap(); match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } // ANCHOR_END: input_system // ANCHOR: main pub fn main() -> GameResult { let mut world = World::new(); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) } // ANCHOR_END: main /* ANCHOR_END: all */
CODELINK: 你可以在 这里 查看本示例的完整代码。
模块
主文件已经变得相当大了,可以想象,随着我们的项目增长,这种方式将无法维持下去。幸运的是,Rust 提供了模块的概念,可以让我们根据关注点将功能整齐地拆分到单独的文件中。
目前,我们的目标是实现以下文件夹结构。随着我们添加更多的组件和系统,我们可能需要不止一个文件,但这是一个不错的起点。
├── resources
│ └── images
│ ├── box.png
│ ├── box_spot.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ ├── systems
│ │ ├── input.rs
│ │ ├── rendering.rs
│ │ └── mod.rs
│ ├── components.rs
│ ├── constants.rs
│ ├── entities.rs
│ ├── main.rs
│ ├── map.rs
│ └── resources.rs
└── Cargo.toml
更多: 阅读更多关于模块和管理增长项目的信息 这里。
首先,让我们将所有组件移到一个文件中。除了将某些字段设置为公共的之外,不会有任何更改。需要将字段设置为公共的原因是,当所有内容都在同一个文件中时,所有内容可以相互访问,这在开始时很方便,但现在我们将内容分开,我们需要更加注意可见性。目前我们将字段设置为公共以使其正常工作,但稍后我们会讨论一种更好的方法。我们还将组件注册移动到了该文件的底部,这样当我们添加组件时,只需要更改这个文件就可以了。
#![allow(unused)] fn main() { // components.rs #[derive(Clone, Copy, Eq, Hash, PartialEq)] pub struct Position { pub x: u8, pub y: u8, pub z: u8, } pub struct Renderable { pub path: String, } pub struct Wall {} pub struct Player {} pub struct Box {} pub struct BoxSpot {} pub struct Movable; pub struct Immovable; }
接下来,让我们将常量移到它们自己的文件中。目前我们硬编码了地图的尺寸,这在移动时需要知道我们何时到达地图的边缘,但作为改进,我们可以稍后存储地图的尺寸,并根据地图加载动态设置它们。
#![allow(unused)] fn main() { // constants.rs pub const TILE_WIDTH: f32 = 32.0; pub const MAP_WIDTH: u8 = 8; pub const MAP_HEIGHT: u8 = 9; }
接下来,实体创建代码现在移到了一个实体文件中。
#![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 { path: "/images/wall.png".to_string(), }, Wall {}, Immovable {}, )) } pub fn create_floor(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 5, ..position }, Renderable { path: "/images/floor.png".to_string(), }, )) } pub fn create_box(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/box.png".to_string(), }, Box {}, Movable {}, )) } pub fn create_box_spot(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 9, ..position }, Renderable { path: "/images/box_spot.png".to_string(), }, BoxSpot {}, )) } pub fn create_player(world: &mut World, position: Position) -> Entity { world.spawn(( Position { z: 10, ..position }, Renderable { path: "/images/player.png".to_string(), }, Player {}, Movable {}, )) } }
现在是地图加载。
#![allow(unused)] fn main() { // map.rs use crate::components::Position; use crate::entities::*; use hecs::World; pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => { create_floor(world, position); } "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } }
最后,我们将系统代码移到它们自己的文件中(RenderingSystem
到 rendering.rs
,InputSystem
到 input.rs
)。这应该只是从主文件中复制粘贴并移除一些导入,因此可以直接进行。
我们需要更新 mod.rs
,告诉 Rust 我们想将所有系统导出到外部(在这里是主模块)。
#![allow(unused)] fn main() { // systems/mod.rs pub mod input; pub mod rendering; }
太棒了,现在我们完成了这些操作,以下是简化后的主文件的样子。注意导入后的 mod
和 use
声明,它们再次告诉 Rust 我们想要使用这些模块。
// main.rs /* ANCHOR: all */ // Rust sokoban // main.rs use ggez::{conf, event, Context, GameResult}; use hecs::World; use std::path; mod components; mod constants; mod entities; mod map; mod systems; // ANCHOR: game // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // ANCHOR_END: game // ANCHOR: handler impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { systems::input::run_input(&self.world, context); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { systems::rendering::run_rendering(&self.world, context); } Ok(()) } } // ANCHOR_END: handler // ANCHOR: main pub fn main() -> GameResult { let mut world = World::new(); map::initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) } // ANCHOR_END: main /* ANCHOR_END: all */
此时可以运行,所有功能应该与之前完全相同,不同的是,现在我们的代码更加整洁,为更多令人惊叹的 Sokoban 功能做好了准备。
CODELINK: 你可以在 这里 查看本示例的完整代码。
实现游戏基本功能
现在角色可以推动箱子在区域内移动了.有些(并不是全部)游戏还会设定些目标让玩家去完成.比如有些推箱子类的游戏会让玩家把箱子推到特定的点才算赢.目前我们还没实现类似的功能,还没有检查什么时候玩家赢了并停止游戏,有可能玩家已经把箱子推到目标点了,但我们的游戏并没意识到.接下来就让我们完成这些功能吧!
首先我们需要想一下要检查是否赢了并通知玩家需要添加那些功能. 当玩家闯关时:
- 需要一个用于保存游戏状态的
resource
- 游戏是在进行中还是已经完成了?
- 玩家目前一共走了多少步了?
- 需要一个用于检查玩家是否完成任务的
system
- 需要一个用于更新移动步数的
system
- 需要一个用于展示游戏状态的界面(UI )
游戏状态资源
我们之所以选择使用资源(resource)
保存游戏状态,是因为游戏状态信息不跟任何一个实体绑定.接下来我们就开始定义一个Gameplay
资源.
#![allow(unused)] fn main() { // resources.rs {{#include ../../../code/rust-sokoban-c02-05/src/resources.rs:38:43}} }
Gameplay
有俩个属性: state
和 moves_count
. 分别用于保存当前游戏状态(当前游戏正在进行还是已经有了赢家)和玩家操作的步数. state
是枚举(enum)
类型, 可以这样定义:
#![allow(unused)] fn main() { // resources.rs {{#include ../../../code/rust-sokoban-c02-05/src/resources.rs:17:20}} }
眼尖的你应该已经发现,我们使用了宏为Gameplay
实现了Default
特征,但是枚举GameplayState
却没有.如果我们需要把Gameplay
用做资源,那它必须具备Default
特征.Rust并没有提供为枚举类型实现Default
特征的宏,我们只能自己为枚举GameplayState
实现Default
特征了.
#![allow(unused)] fn main() { // resources.rs {{#include ../../../code/rust-sokoban-c02-05/src/resources.rs:32:36}} }
定义好资源别忘了注册下:
#![allow(unused)] fn main() { // resources.rs {{#include ../../../code/rust-sokoban-c02-05/src/resources.rs:12:15}} }
当游戏开始时,资源Gameplay
应该是这样地:
#![allow(unused)] fn main() { Gameplay { state: GameplayState::Playing, moves_count: 0 } }
计步System
我们可以通过增加Gameplay
的moves_count
属性值来记录玩家操作的步数.
可以在先前定义的处理用户输入的InputSystem
中实现计步的功能.因为我们需要在InputSystem
中修改Gameplay
的属性值,所以需要在InputSystem
中定义SystemData
类型时使用Write<'a, Gameplay>
.
#![allow(unused)] fn main() { // input_system.rs {{#include ../../../code/rust-sokoban-c02-05/src/systems/input_system.rs:0:25}} ... }
我们先前已经编写过根据玩家按键移动角色的代码,在此基础上再添加增加操作步骤计数的代码就可以了.
#![allow(unused)] fn main() { // input_system.rs ... {{#include ../../../code/rust-sokoban-c02-05/src/systems/input_system.rs:83:105}} }
Gameplay System
接下来是添加一个GamePlayStateSystem
用于检查所有的箱子是否已经推到了目标点,如果已经推到了就赢了.除了 Gameplay
, 要完成这个功能还需要只读访问Position
, Box
, 和 BoxSpot
.这里使用 Join
结合Box(箱子)
和 Position(位置)
创建一个包含每个箱子位置信息的Vector
(集合).我们只需要遍历这个集合判断每个箱子是否在目标点上,如果在就胜利了,如果不在游戏继续.
#![allow(unused)] fn main() { // gameplay_state_system.rs {{#include ../../../code/rust-sokoban-c02-05/src/systems/gameplay_state_system.rs::}} }
最后还需要在渲染循环中执行我们的代码:
#![allow(unused)] fn main() { // main.rs // ANCHOR: handler impl event::EventHandler<ggez::GameError> for Game { 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); } Ok(()) } // ... }
游戏信息界面
最后一步是需要提供一个向玩家展示当前游戏状态的界面.我们需要一个用于记录游戏状态的资源和一个更新状态信息的System.可以把这些放到资源GameplayState
和RenderingSystem
中.
首先需要为GameplayState
实现Display特征,这样才能以文本的形式展示游戏状态.这里又用到了模式匹配,根据游戏的状态显示"Playing(进行中)"或"Won(赢了)".
#![allow(unused)] fn main() { // resources.rs {{#include ../../../code/rust-sokoban-c02-05/src/resources.rs:21:30}} }
接下来我们需要在RenderingSystem
中添加一个方法draw_text
,这样它就可以把游戏状态信息GameplayState
显示到屏幕上了.
#![allow(unused)] fn main() { // rendering_systems.rs {{#include ../../../code/rust-sokoban-c02-05/src/systems/rendering_system.rs:16:32}} }
...为了调用drwa_text
我们还需要把资源 Gameplay
添加 RenderingSystem
中,这样 RenderingSystem
才能获取到资源 Gameplay
.
#![allow(unused)] fn main() { // rendering_system.rs {{#include ../../../code/rust-sokoban-c02-05/src/systems/rendering_system.rs:35:71}} }
至此我们编写的推箱子游戏已经可以向玩家展示基本的信息了:
- 当前的操作步数
- 当玩家胜利时告诉他们
看起来就像这个样子:
还有很多可以改进增强的!
CODELINK: 点 这里获取目前的完整代码.
开发高级功能
恭喜你已经完成了前两章的学习.接下来我们开始学习点更高级的东东.期待不?那就一起来吧!
彩色方块
是时候为我们的游戏增添一些色彩了!到目前为止,游戏玩法相当简单,就是把方块放到指定位置。让我们通过添加不同颜色的方块来让游戏更有趣!现在我们将使用红色和蓝色方块,但你可以根据自己的喜好进行调整,创建更多颜色!现在要赢得游戏,你必须把方块放在相同颜色的目标点上。
资源
首先让我们添加新的资源,右键下载这些图片,或者创建你自己的图片!
目录结构应该是这样的(注意我们已经移除了默认的方块和目标点):
├── resources
│ └── images
│ ├── box_blue.png
│ ├── box_red.png
│ ├── box_spot_blue.png
│ ├── box_spot_red.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ ├── systems
│ │ ├── gameplay.rs
│ │ ├── input.rs
│ │ ├── mod.rs
│ │ └── rendering.rs
│ ├── components.rs
│ ├── constants.rs
│ ├── entities.rs
│ ├── main.rs
│ ├── map.rs
│ └── resources.rs
├── Cargo.lock
├── Cargo.toml
组件更改
现在让我们为颜色添加一个枚举(如果你选择实现两种以上的颜色,你需要在这里添加它们)。
#![allow(unused)] fn main() { // components.rs pub enum BoxColour { Red, Blue, } }
现在让我们在方块和目标点中使用这个枚举。
#![allow(unused)] fn main() { // components.rs pub struct Box { pub colour: BoxColour, } pub struct BoxSpot { pub colour: BoxColour, } }
实体创建
让我们在创建方块和目标点时添加颜色参数,并确保根据颜色枚举传递正确的资源路径。
为了根据颜色创建正确的资源路径字符串,我们基本上想要 "/images/box_{}.png"
,其中 {}
是我们要创建的方块的颜色。现在我们面临的挑战是我们使用的是颜色枚举,所以 Rust 编译器不知道如何将 BoxColour::Red
转换为 "red"
。如果能够使用 colour.to_string()
并获得正确的值就太好了。幸运的是,Rust 为我们提供了一个很好的方法,我们需要在 BoxColour
枚举上实现 Display
特征。下面是具体实现,我们只需指定如何将枚举的每个变体映射到字符串。
#![allow(unused)] fn main() { // components.rs impl Display for BoxColour { fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { fmt.write_str(match self { BoxColour::Red => "red", BoxColour::Blue => "blue", })?; Ok(()) } } }
现在让我们在实体创建代码中包含颜色,并使用我们刚刚实现的 colour.to_string()
功能。
#![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 { path: format!("/images/box_{}.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 { path: format!("/images/box_spot_{}.png", colour), }, BoxSpot { colour }, )) } }
地图
现在让我们修改地图代码以允许新的彩色方块和目标点选项:
- "BB" 表示蓝色方块
- "RB" 表示红色方块
- "BS" 表示蓝色目标点
- "RS" 表示红色目标点
#![allow(unused)] fn main() { // map.rs match *column { "." => { create_floor(world, position); } "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "BB" => { create_floor(world, position); create_box(world, position, BoxColour::Blue); } "RB" => { create_floor(world, position); create_box(world, position, BoxColour::Red); } "BS" => { create_floor(world, position); create_box_spot(world, position, BoxColour::Blue); } "RS" => { create_floor(world, position); create_box_spot(world, position, BoxColour::Red); } "N" => (), c => panic!("unrecognized map item {}", c), } }
让我们在初始化关卡时更新我们的静态地图。
#![allow(unused)] fn main() { // map.rs pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . BB . . W W . . RB . . . W W . P . . . . W W . . . . RS . W W . . BS . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } }
游戏玩法
现在我们已经完成了艰难的工作,可以继续测试这段代码了。你会注意到一切都能工作,但是存在一个重大的游戏玩法错误。你可以通过把红色方块放在蓝色目标点上或反之来赢得游戏。让我们来修复这个问题。
我们之前学过,根据 ECS 方法论,数据放在组件中,行为放在系统中。我们现在讨论的是行为,所以它必须在系统中。还记得我们如何添加检查是否获胜的系统吗?这正是我们需要修改的地方。
让我们修改运行函数,检查目标点和方块的颜色是否匹配。
#![allow(unused)] fn main() { // gameplay.rs use crate::components::*; use hecs::World; use std::collections::HashMap; pub fn run_gameplay_state(world: &World) { // get all boxes indexed by position let mut query = world.query::<(&Position, &Box)>(); let boxes_by_position: HashMap<(u8, u8), &Box> = query .iter() .map(|(_, t)| ((t.0.x, t.0.y), t.1)) .collect::<HashMap<_, _>>(); // loop through all box spots and check if there is a corresponding // box at that position let boxes_out_of_position: usize = world .query::<(&Position, &BoxSpot)>() .iter() .map(|(_, (position, box_spot))| { if let Some(the_box) = boxes_by_position.get(&(position.x, position.y)) { if box_spot.colour == the_box.colour { 0 } else { 1 } } else { 0 } }) .collect::<Vec<usize>>() .into_iter() .sum(); // If we made it this far, then all box spots have boxes on them, and the // game has been won if boxes_out_of_position == 0 { let mut query = world.query::<&mut Gameplay>(); let gameplay = query.iter().next().unwrap().1; gameplay.state = GameplayState::Won; } } }
如果你现在编译代码,它会抱怨我们试图用 ==
比较两个枚举。Rust 默认不知道如何处理这个问题,所以我们必须告诉它。我们能做的最好的方法是为 PartialEq
特征添加一个实现。
#![allow(unused)] fn main() { // components.rs #[derive(PartialEq)] pub enum BoxColour { Red, Blue, } }
现在是讨论这些不寻常的 derive
注解的好时机。我们以前使用过它们,但从未深入探讨它们的作用。派生属性可以应用于结构体或枚举,它们允许我们为我们的类型添加默认的特征实现。例如,这里我们告诉 Rust 为我们的 BoxColour
枚举添加 PartialEq
默认特征实现。
这是 PartialEq
默认实现的样子,它只检查某个东西是否等于它自己。如果相等,比较成功;如果不相等,比较失败。如果这不太容易理解,也不用太担心。
#![allow(unused)] fn main() { pub trait PartialEq { fn eq(&self, other: &Self) -> bool; fn ne(&self, other: &Self) -> bool { !self.eq(other) }; } }
所以通过在枚举上方添加 #[derive(PartialEq)]
,我们告诉 Rust BoxColour
现在实现了我们之前看到的偏等特征,这意味着如果我们尝试进行 box_colour_1 == box_colour_2
,它将使用这个实现,只检查 colour_1 对象是否与 colour_2 对象相同。这不是最复杂的偏等实现,但对我们的用例来说应该足够了。
现在我们可以编译代码并通过看到游戏运行来收获我们努力的成果,只有当我们把正确的方块放在正确的位置时,游戏才会告诉我们赢了!
CODELINK: 你可以在这里看到这个示例的完整代码。
动画
在本节中,我们将学习如何为游戏添加动画,我们将从一些基本的动画开始,但你可以根据本教程中的想法添加更复杂的动画。我们将添加两种动画:让玩家眨眼和让方块在原地轻微晃动。
什么是动画?
动画实际上就是在特定时间间隔播放的一组帧,给人以运动的错觉。可以把它想象成一个视频(视频就是按顺序播放的一系列图像),但帧率要低得多。
例如,要让我们的玩家眨眼,我们需要三个动画帧:
- 我们当前的玩家,眼睛睁开
- 玩家眼睛稍微闭合
- 玩家眼睛完全闭合
如果我们按顺序播放这三帧,你会注意到看起来就像玩家在眨眼。你可以通过打开图像并在图像预览中快速切换它们来试试这个效果。
这里有一些需要注意的事项:
- 资源需要针对特定的帧率设计 - 对我们来说,我们将使用 250 毫秒,这意味着我们每 250 毫秒播放一个新的动画帧,所以我们每秒有 4 帧
- 资源之间需要保持一致性 - 想象一下如果我们有两种不同的玩家,它们有不同的资源和不同外观的眼睛,我们需要确保当我们创建上述三帧时它们是一致的,否则两个玩家会以不同的速率眨眼
- 为大量帧设计资源是一项繁重的工作,所以我们会尽量保持动画简单,只关注关键帧
它将如何工作?
那么这在我们现有的推箱子游戏中将如何工作呢?我们需要:
- 修改我们的可渲染组件以允许多个帧 - 我们也可以创建一个新的可渲染组件来处理动画可渲染对象,并保留现有的组件用于静态可渲染对象,但现在把它们放在一起感觉更整洁
- 修改玩家实体构造以接受多个帧
- 在我们的渲染循环中跟踪时间 - 我们稍后会详细讨论这个问题,所以如果现在不太清楚为什么需要这样做也不用担心
- 修改渲染系统,考虑帧数、时间和在给定时间应该渲染的帧
资源
让我们为玩家添加新的资源,它应该是这样的。注意我们创建了一个按顺序命名帧的约定,这不是严格必要的,但它将帮助我们轻松跟踪顺序。
├── 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
[继续文档的其余部分...]
声音和事件
在这个 section 中,我们将工作于添加事件,这些事件将在后续阶段用来添加声音效果。在短语中,我们想在以下情况下播放声音:
- 当玩家击打墙或障碍时 - 为了让他们知道不能通过
- 当玩家把箱放在正确的位置 - 以表明 "你做得对"
- 当玩家把箱放在错误的位置 - 以表示move的错误
实际上播放声音并不是太难,ggez提供了这个功能,但我们目前面临的问题是需要确定何时播放声音。
让我们从box on correct spot来看。我们可能会使用游戏状态系统,并且会循环遍历boxes和spots来检查是否处于这种情况,然后播放声音。但是,这并不是一种好主意,因为我们将每次循环都尝试多次,造成不必要的重复和播放太快。
我们可以尝试在此过程中保持一些状态,但这并不感兴趣。我们的主要问题是,我们无法通过仅检查状态来做到这一点,而必须使用一种有反应性的模型,当发生某件事情时就能让系统作出反应。
我们会使用事件模型。这意味着当一个框架发生变化(如玩家击打墙或移动箱子)时,将引发一个事件。然后,我们可以在另一端接收这个事件,并根据其类型执行相应的操作。这个系统可以复用。
事件实现
让我们开始 discussing how we will implement events。
- 玩家击打障碍 - 这可以是事件本身,通过输入系统当玩家试图移动但无法移动时会引发
- 箱放在正确或错误的位置 - 我们可以将其表示为一个单独的事件,其中包含是否 correct_spot 的属性(我稍后再解释这个属性)
变化类型
我们需要用enum 来定义各种事件类型。我们曾使用过enum(例如Rendering类型和box颜色),但是这次我们要把它的潜力全推到使用,特别是我们可以在其中添加属性。
查看事件定义,它可能是这样的。
#![allow(unused)] fn main() { // events.rs {{#include '../../../code/rust-sokoban-c03-03/src/events.rs'}} }
事件资源
现在,我们需要一个resource来接收事件。这将是一个多生产者单消费者模型。我们会有多个系统添加事件,而一个system(events system)会只消费该事件。
#![allow(unused)] fn main() { // components.rs {{#include '../../../code/rust-sokoban-c03-03/src/components.rs:events'}} }
发送事件
现在,我们需要将两个事件在input_system中添加:EntityMoved和PlayerHitObstacle。
#![allow(unused)] fn main() { // input.rs {{#include '../../../code/rust-sokoban-c03-03/src/systems/input.rs:run_input}} /// Code omitted /// ...... /// ...... {{#include '../../../code/rust-sokoban-c03-03/src/systems/input.rs:event_add}} } }
消费事件 - events系统
现在它是时候添加一个events system来处理事件。
我们将会对每个事件做出以下决策:
- Event::PlayerHitObstacle -> 这是声音播放的位置,但我们在添加音频部分之前要等
- Event::EntityMoved(EntityMoved { id }) -> 这是我们将在其中添加逻辑来检查移动的实体是否为box,并且它是否位于一个 spot 上
- Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot }) -> 这是声音播放的位置,但在增加音频部分之前要等
#![allow(unused)] fn main() { // systems/events.rs {{#include '../../../code/rust-sokoban-c03-03/src/systems/events.rs'}} }
事件处理系统的结尾很重要,因为处理一个事件可能会导致另一个事件被创建。因此,我们必须将事件添加回世界。
CODELINK:您可以在这个示例中看到完整代码 这里.
声音和事件
Sound Effects
在这个 section 中,我们将在游戏中添加sound effects。我们的目标是在以下情况下播放声音:
- 当玩家撞击 wall 或 obstacle 时—to告知他们无法通过
- 当玩家放置box 在正确位置时—as一种提示“你成功完成了”
- 当玩家放置box 在错误位置时—as一种提示(move 是错的)
音频存储
现在,为了在游戏中播放声音,我们需要先把wav文件加载到一个audio store 中。这将避免每次想要播放声音都在load.wav上。
我们可以使用一个资源来定义音频存储。
#![allow(unused)] fn main() { // components.rs {{#include '../../../code/rust-sokoban-c03-04/src/components.rs:audio_store'}} }
然后,我们需要编写初始化 store 的代码,这意味着在游戏开始前预载所有用于游戏的sound。
我们可以通过在map中创建一个load_sounds函数来实现这一点:
#![allow(unused)] fn main() { {{#include '../../../code/rust-sokoban-c03-04/src/map.rs:load_sounds}} }
当我们是初始化游戏级别时,我们需要调用这个函数。
#![allow(unused)] fn main() { {{#include '../../../code/rust-sokoban-c03-04/src/map.rs:initialize_level}} }
播放声音
最后, 我们需要在 audio store 中添加声音播放的代码。
#![allow(unused)] fn main() { // components.rs {{#include '../../../code/rust-sokoban-c03-04/src/components.rs:audio_store_impl'}} }
然后,在events系统中,我们可以使用这个实现来 playback 鲜样:
#![allow(unused)] fn main() { // systems/events.rs {{#include '../../../code/rust-sokoban-c03-04/src/systems/events.rs}} }
现在,我们就可以在玩家完成各种动作时添加声音!让我们启动游戏并享受这些音频效果!
CODELINK: 你可以在这里.
批量渲染
你可能已经注意到在玩游戏时输入感觉有点慢。让我们添加一个 FPS 计数器来看看我们的渲染速度如何。如果你不熟悉 FPS 这个术语,它代表每秒帧数(Frames Per Second),我们的目标是达到 60FPS。
FPS 计数器
让我们从添加 FPS 计数器开始,这包含两个部分:
- 获取或计算 FPS 值
- 在屏幕上渲染这个值
对于第1点,幸运的是 ggez 提供了获取 fps 的方法 - 参见这里。对于第2点,我们在渲染系统中已经有了渲染文本的方法,所以我们只需要在那里获取 FPS。让我们在代码中把这些都组合起来。
#![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])); /// 代码省略 /// ..... /// ..... // Render FPS let fps = format!("FPS: {:.0}", context.time.fps()); draw_text(&mut canvas, &fps, 525.0, 120.0); /// 代码省略 /// ..... /// ..... // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } }
运行游戏并用按键移动一下,你会看到 FPS 从预期的 60 显著下降。对我来说,它看起来在 20-30 范围内,但根据你的机器可能会更多或更少。
是什么导致了 FPS 下降?
现在你可能会问自己,我们做了什么导致 FPS 这么低?我们有一个相当简单的游戏,我们的输入和移动逻辑实际上并不复杂,我们也没有太多的实体或组件来导致如此大的 FPS 下降。要理解这一点,我们需要更深入地了解我们当前的渲染系统是如何工作的。
目前,对于每个可渲染的实体,我们都要确定要渲染哪个图像并渲染它。这意味着如果我们有 20 个地板贴图,我们将加载地板图像 20 次并发出 20 个单独的渲染调用。这太昂贵了,这就是导致我们 FPS 大幅下降的原因。
我们如何解决这个问题?我们可以使用一种叫做批量渲染的技术。使用这种技术,我们要做的就是只加载一次图像,并告诉 ggez 在所有需要渲染的 20 个位置渲染它。这样我们不仅只加载一次图像,而且每个图像只调用一次渲染,这将大大提高速度。作为旁注,一些引擎会在底层为你完成这种渲染批处理,但 ggez 不会,这就是为什么我们需要关注这一点。
批量渲染实现
以下是我们实现批量渲染需要做的事情:
- 对于每个可渲染实体,确定我们需要渲染的图像和 DrawParams(这是我们目前给 ggez 的渲染位置指示)
- 将所有(图像,DrawParams)保存为一个方便的格式
- 按照 z 轴排序遍历(图像,DrawParams),每个图像只进行一次渲染调用
在深入渲染代码之前,我们需要做一些集合分组和排序,我们将使用 itertools crate 来完成这个任务。我们可以自己实现这个分组,但没有必要重新发明轮子。让我们将 itertools 作为依赖项添加到我们的项目中。
// Cargo.toml
[dependencies]
ggez = "0.9.3"
glam = { version = "0.24", features = ["mint"] }
hecs = "0.10.5"
itertools = "0.13.0"
我们也在渲染系统中导入它
#![allow(unused)] fn main() { // rendering.rs use itertools::Itertools; }
还记得我们在动画章节中编写的用来确定每一帧需要哪个图像的 get_image 函数吗?我们可以重用它,只需要确保我们不实际加载图像,而是返回图像的路径。
#![allow(unused)] fn main() { // rendering.rs pub fn get_image(renderable: &Renderable, delta: Duration) -> String { 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 } }; renderable.path(path_index) } }
现在让我们确定我们想要的批处理数据的格式。我们将使用 HashMap<u8, HashMap<String, Vec<DrawParam>>>
,其中:
- 第一个键(
u8
)是 z 位置 - 记住我们需要遵守 z 位置并从最高到最小的 z 绘制以确保正确的顺序(例如地板应该在玩家下面等) - 值是另一个
HashMap
,其中第二个键(String
)是图像的路径 - 最后,最后的值是一个
Vec<DrawParam>
,它包含了我们必须渲染该特定图像的所有参数
让我们现在编写代码来填充 rendering_batches 哈希映射。
#![allow(unused)] fn main() { // rendering.rs let mut rendering_batches: HashMap<u8, HashMap<String, Vec<DrawParam>>> = HashMap::new(); // Iterate each of the renderables, determine which image path should be rendered // at which drawparams, and then add that to the rendering_batches. for (_, (position, renderable)) in rendering_data.iter() { // Load the image let image_path = get_image(renderable, time.delta); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; let z = position.z; // draw let draw_param = DrawParam::new().dest(Vec2::new(x, y)); rendering_batches .entry(z) .or_default() .entry(image_path) .or_default() .push(draw_param); } }
最后,让我们实际渲染这些批次。我们不能使用之前使用的 draw(image) 函数,但幸运的是 ggez 有一个批处理 API - SpriteBatch。另外注意这里的 sorted_by
,这是 itertools 提供给我们的。
#![allow(unused)] fn main() { // rendering.rs // Iterate spritebatches ordered by z and actually render each of them for (_z, group) in rendering_batches .iter() .sorted_by(|a, b| Ord::cmp(&a.0, &b.0)) { for (image_path, draw_params) in group { let image = Image::from_path(context, image_path).unwrap(); let mut mesh_batch = graphics::InstanceArray::new(context, Some(image)); for draw_param in draw_params.iter() { mesh_batch.push(*draw_param); } canvas.draw(&mesh_batch, graphics::DrawParam::new()); } } }
就是这样!再次运行游戏,你应该看到闪亮的 60FPS,一切都应该感觉更流畅!
CODELINK: 你可以在这里看到这个示例的完整代码。