第一章: 开始编写游戏前
欢迎来到 《使用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.7"
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.7"
specs = { version = "0.17.0", features = ["specs-derive"] }
加油!接下来我们就开始编写组件和实体了!是不是很期待?
CODELINK: 可以点 这里获取本章节完整代码.
组件和实体
嗨,少年!看你骨骼惊奇接下来就开始一起学习怎么结合specs
创建、注册组件和实体。
定义组件
我们先从定义组件开始。先前我们提到过位置组件
、可渲染组件
和动作组件
(这个后面再讲哈)。我们需要用一些组件标识实体,比如可以让一个实体包含墙组件标识它是墙。
可以直接简单的说:位置组件其实就是用来存储地图坐标的x、y、z值的可以用来定位;渲染组件就是使用字符串存储一个需要绘制的图片的路径;另外一些组件基本都是 标记型组件并不存储数据。
#![allow(unused)] fn main() { #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} }
#[storage(VecStorage)]
这原来没见过是不是? 恭喜你,少年!你已经使用到了一个Rust很强大的功能过程宏
。这种宏是一些可以在代码编译时
对代码进行处理并生成新代码的特殊函数。
MORE: 如果你想更深入的了解宏,可以看 这里.
注册组件
在specs
中使用组件前需要先注册组件,就像这样:
把组件注册到world
#![allow(unused)] fn main() { pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } }
创建实体
实体就是代表一系列组件,所以我们创建实体的方法就是简单地指定它们包含哪些组件。就像这个样子:
#![allow(unused)] fn main() { pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } }
素材
睿智如你应该已经注意到了,我们还引用了些用于创建实体的素材,就是图片什么的。当然你要是觉得我们准备的素材不好看,也可以使用自己的素材。我们准备的素材就在下面了,你可以右键另存为下载到电脑上:
接下来把这些图片放到我们的项目中。在项目目录中新建resources
目录,用于存放项目需要用到的资源,目前我们只有图片资源需要存储,以后还会有配置文件啊,音频文件(第三章的第3小节会用到)啊什么的。为了区分不同的资源文件,在resources
目录下再新建一个images
目录,用于存放我们的png图片。你也可以按照自己的喜欢命名目录,除了只要你开心就好,还要记得在代码中引用这些资源时要写出正确的路径。一波操作下来后,我们项目的目录结构大概是这个样子地:
├── resources
│ └── images
│ ├── box.png
│ ├── box_spot.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ └── main.rs
└── Cargo.toml
创建游戏世界(World)
最后,当然只是本小节的最后,接下来在main函数的第一行就创建一个specs::World
对象,把先前创建的实体还有素材都整合到一起。
// Rust sokoban // main.rs use ggez::{conf, event, Context, GameResult}; use specs::{Builder, Component, VecStorage, World, WorldExt}; use std::path; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} // 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, } impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, _context: &mut Context) -> GameResult { Ok(()) } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } pub fn main() -> GameResult { let mut world = World::new(); register_components(&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) }
然后你就可以执行cargo run
运行看下效果,当你满怀期待却发现看到的依然是一个空白的窗口,控制台里可能还多了些警告信息。这是因为我们还没有编写渲染的代码也就是还没有绘制这些实体。少侠,莫急!下一节,我们就开始绘制。到时这些因为引入而没有使用的警告也就自然消失了。
CODELINK: 你可以在 这里找到本小节完整的代码.
渲染系统
是时候开始创建第一个系统(system
)了——渲染系统。这个系统负责把实体绘制到屏幕上,也就是能不能在窗口上看见点东西就看它的了。
渲染系统走起
首先我们定义个结构体RenderingSystem
,它需要使用ggez
的上下文对象(context
)绘制实体。
#![allow(unused)] fn main() { pub struct RenderingSystem<'a> { context: &'a mut Context, } }
注意代码中的'a, ' 可不是单引号哦,在你的键盘上应该也是Esc
键下面的那个建。这是什么东东,为何写法如此奇怪嘞?这是Rust的生命周期声明语法。因为Rust编译器自己推断不出结构体RenderingSystem
持有的Context
引用的有效性,所以需要我们使用生命周期声明语法告诉它。
MORE: 更深入的了解生命周期请点 这里.
接下来我们需要为结构体RenderingSystem
实现System
特征。当前只是编写个架子,并不对方法做具体实现。
#![allow(unused)] fn main() { // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // implementation here } } }
代码中定义的SystemData
类型是方便访问位置和可渲染存储信息的。我们使用了只读存储ReadStorage
,也就是只读取数据不修改数据。
最后在绘制循环中运行渲染系统。也就是当每次游戏更新时也同时根据实体的最新状态重新绘制实体。
#![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 { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } } }
现在我们的代码是可以编译运行的,但是依然看不到任何东西,因为我们还没编写渲染的逻辑代码,也还没创建实体。
实现渲染系统
实现渲染系统需要做这些事:
- 清空屏幕(确保不显示过去的
帧
) - 获取所有具备可渲染组件的实体,并按空间z轴排列好后渲染。这样可以保证实体可以一层一层累加渲染,比如玩家应该在地板上面,不然我们就看不到他了。
- 按排列好的顺序一个一个的把实体渲染为图片展示。
- 最后就可以在屏幕上看到它们了。
#![allow(unused)] fn main() { fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the background colour) graphics::clear(self.context, graphics::Color::new(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 rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.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::new(self.context, renderable.path.clone()).expect("expected image"); 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)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } }
添加实体测试下
接下来我们创建一些用来测试的实体,验证下我们的代码是不是可以正常工作。
#![allow(unused)] fn main() { 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 }, ); } }
最后我们把所有这些都整合到一起,然后编译运行,你会看到:
是不是小激动?这是我们第一次实现了个渲染系统在窗口上绘制出了点东西。小激动一下就可以了,毕竟现在还只是显示了些静态的图片还不能称之为游戏,后面我们会让它更像个游戏。
最终的代码是这个样子的:
注意: 当前实现的渲染系统还比较简单,随着实体的增多可能会有性能问题。在第三章的批量渲染章节我们还会做些优化,敬请期待!
// Rust sokoban // main.rs use glam::Vec2; use ggez::{conf, event, Context, GameResult, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, }; use std::path; const TILE_WIDTH: f32 = 32.0; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} // Systems pub struct RenderingSystem<'a> { context: &'a mut Context, } // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the background colour) graphics::clear(self.context, graphics::Color::new(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 rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.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::new(self.context, renderable.path.clone()).expect("expected image"); 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)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } // 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, } // 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 { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } // 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 }, ); } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); 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) }
CODELINK: 可以点 这里获取本章节完整代码.
第二章: 实现基本功能
你好棒棒哦,已经读到第2章啦!在这一章中我们将实现一些游戏的基本功能比如:加载地图,让角色动起来等,总之完成了这一章,我们的程序就有点游戏的意思了.激不激动,让我们继续前进,前进,前进进!
加载地图
在上一章中为了测试渲染系统是否正常,我们编写了一些实体.接下来是时候渲染一个合适的地图了.在这一节中我们就先创建一个文本格式的地图配置文件,后面再加载这个配置文件.以此学习怎么创建加载地图.
地图配置
首先让我们看一个二维的地图:
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
where:
. is an empty spot
W is a wall
P is the player
B is a box
S is a box spot
N is nothing: used for the outer edges of the map
为了简单起见我们直接使用一个字符串常量保存地图信息,就不把它放在一个文件中再读取了:
#![allow(unused)] fn main() { 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()); } }
接下来是编写加载地图(函数load_map)的代码:
#![allow(unused)] fn main() { 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
.不过这里我们只是用简单的模式匹配功能分别处理地图配置中的每一个字符,模式匹配(match)还有很多更高级的用法,比如:条件判断,类型匹配等.
MORE: 想了解更多模式匹配的功能可以看 这里.
现在可以运行下我们的游戏,如果你是跟这我们一起编写的,它看起来应该像这样:
下面是完整代码:
// Rust sokoban // main.rs use glam::Vec2; use ggez::{conf, event, Context, GameResult, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, }; use std::path; const TILE_WIDTH: f32 = 32.0; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} // Systems pub struct RenderingSystem<'a> { context: &'a mut Context, } // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(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 rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.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::new(self.context, renderable.path.clone()).expect("expected image"); 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)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } // 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, } // 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 { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } // 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), } } } } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); 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) }
CODELINK: 可以从这里获取当前实例的完整代码.
让角色动起来
严格来说当前我们编写的还称不上游戏,因为还不能让玩家操作角色动起来.在这一节我们就开始学习怎么获取用户输入事件从而让角色动起来.
输入事件
要让玩家可以操作角色动起来,首先我们需要监听用户输入事件.怎么监听呢?可以参考ggez提供的例子.其中有监听鼠标和键盘事件的示例代码,现在我们只需要监听键盘按下(key_down_event
)事件.比虎画猫让我们开始编写代码吧!
首先引入下需要用到的模块:
#![allow(unused)] fn main() { // Rust sokoban // main.rs use glam::Vec2; use ggez::{conf, Context, GameResult, event::{self, KeyCode, KeyMods}, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, Write, WriteStorage, }; }
接下来为Game实现event::EventHandler
,这样我们的游戏就可以监听到键盘按键按下的事件了:
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { // ... fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); } // ... } }
你可以运行代码,按下方向键试一下,在控制台中就会输出类似下面的信息:
Key pressed: Left
Key pressed: Left
Key pressed: Right
Key pressed: Up
Key pressed: Down
Key pressed: Left
是不是很神奇?
在使用println
输出信息时使用了{:?}
,这个是Rust提供的方便调试的,比如我们这里输出的keycode
其实是一个枚举对象,因为它实现了Debug特征,所以这里可以很方便的把它转换为字符串输出到控制台.如果要对没有实现Debug特征的对象使用{:?}
,代码就该编译出错了,好在Rust提供了Debug宏可以非常简单方便实现Debug特征.我们在第1章的第3节介绍过宏,如果对宏不是很了解也可以回头再看一下.
资源
资源是用于在系统中共享状态信息的.为什么需要资源呢?因为组件实体模型不适合干这样的事.
接下来我们将添加一个资源,一个用于记录用户按键的队列.
#![allow(unused)] fn main() { // Resources #[derive(Default)] pub struct InputQueue { pub keys_pressed: Vec<KeyCode>, } }
当用户按下了按键,Game的方法key_down_event
就会执行,这个我们上面已经试过了.现在我们需要在key_down_event方法中把keycode
添加到队列里:
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { // ... fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); let mut input_queue = self.world.write_resource::<InputQueue>(); input_queue.keys_pressed.push(keycode); } // ... } }
最后我们还需要注册下资源,就像注册组件一样.
// Registering resources pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } // Registering resources in main pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); register_resources(&mut world); 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)
输入处理
到这里我们已经有了一个持续记录用户按键的队列,接下来就是在系统中处理这个队列了,准确来说是处理队列中记录的按键.
#![allow(unused)] fn main() { pub struct InputSystem {} impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, InputQueue>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, mut positions, players) = data; for (position, _player) in (&mut positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // Apply the key to the position match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } }
最后我们还需要在渲染循环中运行输入处理代码.
#![allow(unused)] fn main() { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } Ok(()) } }
当前的输入处理代码非常简单,就是根据玩家的输入控制角色的位置(虽然我们当前只有一个角色,但是理论上对于多个玩家多个角色的场景也可以这么玩).
酷不? 运行下代码应该就是这样的:
注意到没?现在角色可以穿过墙和盒子.没关系,我们下一节就修复这个问题.
CODELINK: 可以点这里获取当前完整代码.
推箱子
在前一节中我们可以控制角色移动了,但是角色可以穿越墙和盒子,对环境中的其它东西根本都不care.做人不能这么横,咋能这么目中无箱子呢?这样还咋推箱子呢?接下来我们就在角色移动的时候添加些逻辑判断让它智能起来.
可移动的组件
首先我们需要让代码更通用些.在先前场景我们完成了让玩家控制角色移动的功能,后面我们还需要让箱子也移动起来.说不定以后我们还需要移动其它的什么东西.所以很有必要把组件区分为可移动的和不可移动的.比如:角色箱子是可以移动的;墙是不能移动,角色也不能穿过的;另外还有方框即不像可移动的角色也不像不可移动的墙.
接下来我们增加2个新组件,除了一些小的改变其实也没什么新的:
- 我们使用
NullStorage
而不是VecStorage
,因为这俩个组件没什么属性,只是用做标记,使用NullStorage
更高效. - 为了使用
NullStorage
还需要实现Default
特征. - 把这俩个组件添加到register_components函数中.
#![allow(unused)] fn main() { #[derive(Component, Default)] #[storage(NullStorage)] pub struct Movable; #[derive(Component, Default)] #[storage(NullStorage)] pub struct Immovable; world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); world.register::<Movable>(); world.register::<Immovable>(); } pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } }
接下来:
- 为角色和箱子实现with(Movable)
- 为墙实现with(Immovable)
- 地板和方框就不做什么处理了(就像我们先前分析的,方框即不能移动也不能像墙一样阻止角色箱子移动)
#![allow(unused)] fn main() { .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .with(Immovable) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .with(Movable) .build(); } // Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " }
移动需求分析
现在我们可以想几种不同的移动场景,这样有助于更好的使用Moveable
和Immovable
.
场景:
(player, floor)
+RIGHT
-> 角色应右移(player, wall)
+RIGHT
-> 角色不能移动(player, box, floor)
+RIGHT
-> 角色和盒子右移(player, box, wall)
+RIGHT
-> 啥都不移动(player, box, box, floor)
+RIGHT
-> 角色, box1 和 box2 都应右移(player, box, box, wall)
+RIGHT
-> 啥都不移动
由此可以得出:
- 碰撞(移动)检测需要所有相关对象一起完成.比如场景6, 如果我们一次检测一个对象,我们就会移动角色,然后移动box1,直到我们检测到box2,才发现box2是不能移动的,因为它前面是个墙,那先前移动的角色和box1还得移动回去.
- 可移动对象碰到空点可以移动的(这里的空点代表即不是可移动对象也不是不可移动对象).
- 可移动对象碰到不可移动对象不可移动.
- 虽然我们只是根据向右移动的场景分析的,但这些结论同样适用于其它方向.
基于此,就可以编写代码了. 以下是一些我们需要实现的逻辑片段:
- 找出所有的可移动实体和不可移动实体 - 这样我们才能区分它们是否对应对有影响.
- 确定基于按键移动实体的方法 - 这个我们先前章节中已经介绍过,可以根据按键在实体位置相应分支上+1或-1;
- 遍历从角色当前位置到地图末尾的所有位置 需要根据移动方向判断地图末尾位置,比如按下的向右方向键,那我们就需要遍历玩家当前位置到
地图宽度
的所有位置信息;如果按下的是向上方向键,那就需要从0遍历到player.y
. - 对于序列中的每个图块 我们需要:
- 如果当前图块是可移动的,就继续执行并记录下当前的图块.
- 如果当前图块是不可移动的,就停止执行,什么也不需要移动.
- 如果当前图块既不是可移动的也不是不可移动的,就移动我们先前记录的所有可移动图块.
下面是新的输入处理的代码,虽然代码有点长但功能实现了!
#![allow(unused)] fn main() { // Data type SystemData = ( Write<'a, InputQueue>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, entities, mut positions, players, movables, immovables) = data; let mut to_move = Vec::new(); for (position, _player) in (&positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // get all the movables and immovables let mov: HashMap<(u8, u8), Index> = (&entities, &movables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); let immov: HashMap<(u8, u8), Index> = (&entities, &immovables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); // Now iterate through current position to the end of the map // on the correct axis and check what needs to move. let (start, end, is_x) = match key { KeyCode::Up => (position.y, 0, false), KeyCode::Down => (position.y, MAP_HEIGHT, false), KeyCode::Left => (position.x, 0, true), KeyCode::Right => (position.x, MAP_WIDTH, 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(id) => to_move.push((key, id.clone())), 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 (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } }
现在再运行代码,你会发现角色不再穿越墙了还能推动箱子了.
下面是完整代码:
// Rust sokoban // main.rs use glam::Vec2; use ggez::{ conf, Context, GameResult, event::{self, KeyCode, KeyMods}, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, Write, WriteStorage, NullStorage, Entities, world::Index }; use std::collections::HashMap; use std::path; const TILE_WIDTH: f32 = 32.0; const MAP_WIDTH: u8 = 8; const MAP_HEIGHT: u8 = 9; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} #[derive(Component, Default)] #[storage(NullStorage)] pub struct Movable; #[derive(Component, Default)] #[storage(NullStorage)] pub struct Immovable; // Resources #[derive(Default)] pub struct InputQueue { pub keys_pressed: Vec<KeyCode>, } // Systems pub struct RenderingSystem<'a> { context: &'a mut Context, } // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(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 rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.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::new(self.context, renderable.path.clone()).expect("expected image"); 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)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } pub struct InputSystem {} // System implementation impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, InputQueue>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, entities, mut positions, players, movables, immovables) = data; let mut to_move = Vec::new(); for (position, _player) in (&positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // get all the movables and immovables let mov: HashMap<(u8, u8), Index> = (&entities, &movables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); let immov: HashMap<(u8, u8), Index> = (&entities, &immovables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); // Now iterate through current position to the end of the map // on the correct axis and check what needs to move. let (start, end, is_x) = match key { KeyCode::Up => (position.y, 0, false), KeyCode::Down => (position.y, MAP_HEIGHT, false), KeyCode::Left => (position.x, 0, true), KeyCode::Right => (position.x, MAP_WIDTH, 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(id) => to_move.push((key, id.clone())), 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 (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } // 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, } // 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 { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); let mut input_queue = self.world.write_resource::<InputQueue>(); input_queue.keys_pressed.push(keycode); } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); world.register::<Movable>(); world.register::<Immovable>(); } pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .with(Immovable) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .with(Movable) .build(); } // 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), } } } } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); register_resources(&mut world); 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) }
CODELINK: 你可以点 这里获取目前的完整代码.
模块化
main.rs
文件已经太大了,随着功能的增加,这样下去还会越来越大,越来越难于维护.怎么办呢?还好Rust支持模块
,可以把程序按照功能拆分到不同的文件中.
那么现在就让我们开始拆分吧,先看下目录结构:
├── resources
│ └── images
│ ├── box.png
│ ├── box_spot.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ ├── systems
│ │ ├── input_system.rs
│ │ ├── mod.rs
│ │ └── rendering_system.rs
│ ├── components.rs
│ ├── constants.rs
│ ├── entities.rs
│ ├── main.rs
│ ├── map.rs
│ └── resources.rs
└── Cargo.toml
MORE: 想了解更多关于模块的知识可以点 这里.
接下来我们就开始把每一个组件放到一个文件中.放到单独的文件中后除了要把属性声明成public
的,也没什么不一样的.之所以现在需要把属性声明成public
的是因为先前都在一个文件中,刚开始可以都放在一个文件中,但随着文件越来越大我们就需要把代码拆分到不同的文件中了,为了保证不同的文件(模块)间还能互相访问的到就需要把属性声明成public
的,这样代码就不会报错了.我们后面也会介绍另外一种拆分方式.另外把注册组件的代码放到文件的下面.拆分好后如果需要修改或者增加组件只需要修改对应的文件就可以了.
#![allow(unused)] fn main() { // components.rs use specs::{Component, NullStorage, VecStorage, World, WorldExt}; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { pub x: u8, pub y: u8, pub z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { pub path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} #[derive(Component, Default)] #[storage(NullStorage)] pub struct Movable; #[derive(Component, Default)] #[storage(NullStorage)] pub struct Immovable; pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); world.register::<Movable>(); world.register::<Immovable>(); } }
下面是资源文件:
#![allow(unused)] fn main() { // resources.rs use ggez::event::KeyCode; use specs::World; // Resources #[derive(Default)] pub struct InputQueue { pub keys_pressed: Vec<KeyCode>, } pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } }
接下来我们把常量也拆分到一个单独文件中.先前地图的维度信息是在代码中硬编码的,最好是根据加载地图的维度动态设置.
#![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; }
接下来把创建实体的代码放到entities.rs
文件中:
#![allow(unused)] fn main() { // entities.rs use crate::components::*; use specs::{Builder, World, WorldExt}; // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .with(Immovable) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .with(Movable) .build(); } }
下面是地图加载的代码:
#![allow(unused)] fn main() { // map.rs use crate::components::Position; use crate::entities::*; use specs::World; 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), } } } } }
最后我们再把渲染代码放到randering_system.rs
文件中,把输入处理代码放到input_system.rs
文件中,其实也就是复制粘贴改下导入语句.
现在还有个有意思的事,在一个文件夹下包含了多个文件.如果不做些其它操作在main.rs
文件中要使用RenderingSystem
或者InputSystem
程序会报错的.咋办呢?只需在文件夹下添加一个mod.rs
文件告诉Rust当前这个文件夹下包含那些内容.这样在外部就可以访问RenderingSystem和InputSystem了.
#![allow(unused)] fn main() { // systems/mod.rs mod input_system; mod rendering_system; pub use self::input_system::InputSystem; pub use self::rendering_system::RenderingSystem; }
齐活了!现在再看main.rs
是不是清爽多了!注意我们用了一些mod
告诉Rust需要用到的模块.
// main.rs // Rust sokoban // main.rs use ggez::{conf, event::{self, KeyCode, KeyMods}, Context, GameResult}; use specs::{RunNow, World, WorldExt}; use std::path; mod components; mod constants; mod entities; mod map; mod resources; mod systems; use crate::components::*; use crate::map::*; use crate::resources::*; use crate::systems::*; struct Game { world: World, } impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); let mut input_queue = self.world.write_resource::<InputQueue>(); input_queue.keys_pressed.push(keycode); } } // 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 main() -> GameResult { let mut world = World::new(); register_components(&mut world); register_resources(&mut world); 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) }
至此拆分模块的任务就完成了,运行下代码应该跟先前的功能是一样的,但代码没那么臃肿了也方便后续添加新功能了.
CODELINK: 点 这里获取当前完整代码.
实现游戏基本功能
现在角色可以推动箱子在区域内移动了.有些(并不是全部)游戏还会设定些目标让玩家去完成.比如有些推箱子类的游戏会让玩家把箱子推到特定的点才算赢.目前我们还没实现类似的功能,还没有检查什么时候玩家赢了并停止游戏,有可能玩家已经把箱子推到目标点了,但我们的游戏并没意识到.接下来就让我们完成这些功能吧!
首先我们需要想一下要检查是否赢了并通知玩家需要添加那些功能. 当玩家闯关时:
- 需要一个用于保存游戏状态的
resource
- 游戏是在进行中还是已经完成了?
- 玩家目前一共走了多少步了?
- 需要一个用于检查玩家是否完成任务的
system
- 需要一个用于更新移动步数的
system
- 需要一个用于展示游戏状态的界面(UI )
游戏状态资源
我们之所以选择使用资源(resource)
保存游戏状态,是因为游戏状态信息不跟任何一个实体绑定.接下来我们就开始定义一个Gameplay
资源.
#![allow(unused)] fn main() { // resources.rs #[derive(Default)] pub struct Gameplay { pub state: GameplayState, pub moves_count: u32 } }
Gameplay
有俩个属性: state
和 moves_count
. 分别用于保存当前游戏状态(当前游戏正在进行还是已经有了赢家)和玩家操作的步数. state
是枚举(enum)
类型, 可以这样定义:
#![allow(unused)] fn main() { // resources.rs pub enum GameplayState { Playing, Won } }
眼尖的你应该已经发现,我们使用了宏为Gameplay
实现了Default
特征,但是枚举GameplayState
却没有.如果我们需要把Gameplay
用做资源,那它必须具备Default
特征.Rust并没有提供为枚举类型实现Default
特征的宏,我们只能自己为枚举GameplayState
实现Default
特征了.
#![allow(unused)] fn main() { // resources.rs impl Default for GameplayState { fn default() -> Self { Self::Playing } } }
定义好资源别忘了注册下:
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); } }
当游戏开始时,资源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 use crate::components::*; use crate::constants::*; use crate::resources::{InputQueue, Gameplay}; use ggez::event::KeyCode; use specs::{world::Index, Entities, Join, ReadStorage, System, Write, WriteStorage}; use std::collections::HashMap; pub struct InputSystem {} // System implementation impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, InputQueue>, Write<'a, Gameplay>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, mut gameplay, entities, mut positions, players, movables, immovables) = data; ... }
我们先前已经编写过根据玩家按键移动角色的代码,在此基础上再添加增加操作步骤计数的代码就可以了.
#![allow(unused)] fn main() { // input_system.rs ... // We've just moved, so let's increase the number of moves if to_move.len() > 0 { gameplay.moves_count += 1; } // Now actually move what needs to be moved for (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } }
Gameplay System
接下来是添加一个GamePlayStateSystem
用于检查所有的箱子是否已经推到了目标点,如果已经推到了就赢了.除了 Gameplay
, 要完成这个功能还需要只读访问Position
, Box
, 和 BoxSpot
.这里使用 Join
结合Box(箱子)
和 Position(位置)
创建一个包含每个箱子位置信息的Vector
(集合).我们只需要遍历这个集合判断每个箱子是否在目标点上,如果在就胜利了,如果不在游戏继续.
#![allow(unused)] fn main() { // gameplay_state_system.rs use specs::{Join, ReadStorage, System, Write}; use std::collections::HashMap; use crate::{ components::{Box, BoxSpot, Position}, resources::{Gameplay, GameplayState}, }; pub struct GameplayStateSystem {} impl<'a> System<'a> for GameplayStateSystem { // Data type SystemData = ( Write<'a, Gameplay>, ReadStorage<'a, Position>, ReadStorage<'a, Box>, ReadStorage<'a, BoxSpot>, ); fn run(&mut self, data: Self::SystemData) { let (mut gameplay_state, positions, boxes, box_spots) = data; // get all boxes indexed by position let boxes_by_position: HashMap<(u8, u8), &Box> = (&positions, &boxes) .join() .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 for (_box_spot, position) in (&box_spots, &positions).join() { if boxes_by_position.contains_key(&(position.x, position.y)) { // continue } else { gameplay_state.state = GameplayState::Playing; return; } } // If we made it this far, then all box spots have boxes on them, and the // game has been won gameplay_state.state = GameplayState::Won; } } }
最后还需要在渲染循环中执行我们的代码:
#![allow(unused)] fn main() { // main.rs impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } // Run gameplay state system { let mut gss = GameplayStateSystem {}; gss.run_now(&self.world); } Ok(()) } // ... } }
游戏信息界面
最后一步是需要提供一个向玩家展示当前游戏状态的界面.我们需要一个用于记录游戏状态的资源和一个更新状态信息的System.可以把这些放到资源GameplayState
和RenderingSystem
中.
首先需要为GameplayState
实现Display特征,这样才能以文本的形式展示游戏状态.这里又用到了模式匹配,根据游戏的状态显示"Playing(进行中)"或"Won(赢了)".
#![allow(unused)] fn main() { // resources.rs impl Display for GameplayState { fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { fmt.write_str(match self { GameplayState::Playing => "Playing", GameplayState::Won => "Won" })?; Ok(()) } } }
接下来我们需要在RenderingSystem
中添加一个方法draw_text
,这样它就可以把游戏状态信息GameplayState
显示到屏幕上了.
#![allow(unused)] fn main() { // rendering_systems.rs impl RenderingSystem<'_> { pub fn draw_text(&mut self, text_string: &str, x: f32, y: f32) { let text = graphics::Text::new(text_string); let destination = Vec2::new(x, y); let color = Some(Color::new(0.0, 0.0, 0.0, 1.0)); let dimensions = Vec2::new(0.0, 20.0); graphics::queue_text(self.context, &text, dimensions, color); graphics::draw_queued_text( self.context, graphics::DrawParam::new().dest(destination), None, graphics::FilterMode::Linear, ) .expect("expected drawing queued text"); } } }
...为了调用drwa_text
我们还需要把资源 Gameplay
添加 RenderingSystem
中,这样 RenderingSystem
才能获取到资源 Gameplay
.
#![allow(unused)] fn main() { // rendering_system.rs impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (Read<'a, Gameplay>, ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (gameplay, positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(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 rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.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::new(self.context, renderable.path.clone()).expect("expected image"); 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)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Render any text self.draw_text(&gameplay.state.to_string(), 525.0, 80.0); self.draw_text(&gameplay.moves_count.to_string(), 525.0, 100.0); // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } }
至此我们编写的推箱子游戏已经可以向玩家展示基本的信息了:
- 当前的操作步数
- 当玩家胜利时告诉他们
看起来就像这个样子:
还有很多可以改进增强的!
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_state_system.rs
│ │ ├── input_system.rs
│ │ ├── mod.rs
│ │ └── rendering_system.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 #[derive(Component)] #[storage(VecStorage)] pub struct Box { pub colour: BoxColour, } #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot { pub colour: BoxColour, } }
创建实体
在创建盒子和目标点时还需要把颜色做为参数,并根据具体颜色加载相应的图片素材.
我们可以把素材的加载路径设置为 "/images/box_{}.png"
,其中 {}
根据颜色取相应的值.这里我们就需要把枚举类型的颜色值转换为字符串.比如把 BoxColour::Red
转换为"red"
. 如果能调用 colour.to_string()
转换就好了. 幸运的是Rust提供了 Display
特征,我们只需要为 BoxColour
枚举类型实现这个特征就可以把它转换为相应的字符串了:
#![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(()) } } }
接下来就可以在创建实体时使用颜色了:
#![allow(unused)] fn main() { // entities.rs pub fn create_box(world: &mut World, position: Position, colour: BoxColour) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: format!("/images/box_{}.png", colour), }) .with(Box { colour }) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position, colour: BoxColour) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: format!("/images/box_spot_{}.png", colour), }) .with(BoxSpot { colour }) .build(); } }
地图
现在我们还需要修改下地图映射代码,以支持有颜色的盒子和目标点:
- "BB" 表示蓝色的盒子
- "RB" 表示红色的盒子
- "BS" 表示蓝色的目标点
- "RS" 表示红色的目标点
#![allow(unused)] fn main() { // map.rs use crate::components::{BoxColour, Position}; use crate::entities::*; use specs::World; 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); } "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), } } } } }
接下来还需要在main.rs
文件中修改我们的静态地图:
#![allow(unused)] fn main() { // main.rs // 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 . . . 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()); } }
试玩
终于完成了,现在可以运行代码试玩下了.你会发现代码虽然可以正常运行,但是当我们把红色的盒子推到蓝色的目标点上时也会提示获胜了!这跟我们先要的玩法不一样啊,那咋办呢?
我们可以在判断是否获胜的代码里加上判断盒子和目标点的颜色是否一致的代码:
#![allow(unused)] fn main() { // gameplay_state_system.rs fn run(&mut self, data: Self::SystemData) { let (mut gameplay_state, positions, boxes, box_spots) = data; // get all boxes indexed by position let boxes_by_position: HashMap<(u8, u8), &Box> = (&positions, &boxes) .join() .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. since we now have different types of boxes // we need to make sure the right type of box is on the right // type of spot. for (box_spot, position) in (&box_spots, &positions).join() { if let Some(the_box) = boxes_by_position.get(&(position.x, position.y)) { if the_box.colour == box_spot.colour { // continue } else { // return, haven't won yet return; } } else { gameplay_state.state = GameplayState::Playing; return; } } // If we made it this far, then all box spots have boxes on them, and the // game has been won gameplay_state.state = GameplayState::Won; } } }
现在编译代码,编译器会报错,因为Rust不知道怎么对俩个枚举值进行 ==
操作.怎么告诉Rust怎么处理呢?可以为颜色枚举类型实现 PartialEq
特征.
#![allow(unused)] fn main() { // components.rs #[derive(PartialEq)] pub enum BoxColour { Red, Blue, } }
这里我们使用了扩展 derive
注解. 先前我们也用过这个注解只是没做过多介绍.扩展Deribe
注解可以被用在结构体和枚举类型上,帮他们快速实现一些特征.比如我们这里使用的#[derive(PartialEq)]
就是为枚举类型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) }; } }
虽然只是简单的判断了两个对象是否相等,如果相等就返回true,如果不相等就返回false,但对我们当前的应用场景已经够用了.
也就是说由于我们通过给枚举类型BoxColor
添加#[derive(PartialEq)]
注解实现了PartialEq
特征,当我们在使用==
比较俩个颜色时,Rust就知道怎么处理了.它会判断这俩个颜色值是不是一样的.如果一样就返回true,如果不一样就返回false.
现在再编译运行代码,就可以基于颜色判断是否获胜了.
CODELINK: 可以点 这里获取当前完整代码.
动画
在这节中我们给游戏添加点动画效果.这里使用的动画效果比较简单,你也可以基于此添加更复杂的动画效果.这节我们将添加俩种动画效果:让角色眨眼和让盒子抖动.
什么是动画呢?
简单来说动画就是按照特定时间间隔展现一系列帧,从而让素材动起来,就像视频(视频就是播放一系列图片),但是使用的帧率比较低.
比如要让角色眨眼睛我们需要3个动画帧(图片).
For example, to get our player blinking we'll have three animation frames:
- 一张眼睛张开的角色图片
- 一张眼睛微闭的角色图片
- 一张眼睛完全闭上的角色图片
你可以通过图片浏览器快速的翻动图片尝试按顺序播放这三张图片,会看到角色在眨眼睛.
还需要说明的是:
- 需要按照一个特定的帧率播放素材 - 每250毫秒播放一个图片也就是每秒种播放4个图片.
- 素材必须连贯- 想象一下我们已经有了两张不同眼睛状态的图片,现在如果第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
为参数,在其它编程语言中可能叫静态函数.你可以把他们看成是工厂函数,因为他们封装了验证和构建对象的逻辑.
MORE: 点 这里获取更多关联函数知识.
#![allow(unused)] fn main() { // components.rs #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { paths: Vec<String>, } impl Renderable { pub fn new_static(path: String) -> Self { Self { paths: vec![path] } } pub fn new_animated(paths: Vec<String>) -> Self { Self { paths } } } }
接下来我们需要在渲染系统中判断可渲染组件是有动画效果的还是静态的.我们可以通过获取到的图片路径是一个还是多个来判断是哪种类型的渲染组件,还有种更专业的做法:创建一个枚举对象用于表示可渲染组件类型,然后在可渲染对象中添加一个函数用于获取渲染类型.这样就可以把判断渲染类型的逻辑封装在一个函数中,也不需要对外部公开path
属性了.可以在components.rs
的任何地方定义我们的枚举类型,但最好紧挨着渲染组件.
#![allow(unused)] fn main() { // components.rs pub enum RenderableKind { Static, Animated, } }
现在我们增加根据paths
的长度判断渲染类型的函数:
#![allow(unused)] fn main() { // components.rs impl Renderable { pub fn new_static(path: String) -> Self { Self { paths: vec![path] } } pub fn new_animated(paths: Vec<String>) -> Self { Self { paths } } pub fn kind(&self) -> RenderableKind { match self.paths.len() { 0 => panic!("invalid renderable"), 1 => RenderableKind::Static, _ => RenderableKind::Animated, } } } }
最后,由于paths
属性是私有的外部不能获取,我们需要提供一个公开函数让外部可以获取到图片路径.对于静态可渲染组件只需要返回第一个路径就可以了.对于动态可渲染组件可以根据索引获取到相应的图片,这里有点小难的是当索引超过路径总数时,我们需要先使用路径总数对索引取模,再使用取模后的值做为索引获取路径.
#![allow(unused)] fn main() { // components.rs impl Renderable { //... 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_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable::new_animated(vec![ "/images/player_1.png".to_string(), "/images/player_2.png".to_string(), "/images/player_3.png".to_string(), ])) .with(Player {}) .with(Movable) .build(); } }
然后是使用 new_static
函数创建墙等静态组件(其它静态组件与墙类似,不再一一贴出).
#![allow(unused)] fn main() { // entities.rs pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable::new_static("/images/wall.png".to_string())) .with(Wall {}) .with(Immovable) .build(); } }
渲染时机
我们还需要一个用于确定动画渲染时机的组件.我们该在什么时间怎样按照特定帧率执行动画渲染操作?简单的做法是:ggez控制着渲染系统的执行周期,但这个周期取决于每次循环时执行的工作量,也就是说这个频率是不稳定的,1秒钟可能执行60次也可能执行57次,甚至只执行30次.这也就意味着我们不能确保动画系统按照特定的频率渲染,我们需要自己控制动画的渲染时机.
因此我们需要跟踪记录空闲时间或者说是上一个循环结束到当前循环的时间.由于空闲时间比帧间隔(250ms)小的多,我们需要记录下累积的空闲时间,也就是从游戏运行开始的所以空闲时间.
现在就让我们添加一个时间资源,之所以是资源因为它需要用于记录全局状态信息而不适合组件模型.
#![allow(unused)] fn main() { // resources.rs #[derive(Default)] pub struct Time { pub delta: Duration, } }
别忘了注册下新创建的资源:
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); world.insert(Time::default()); } }
接下来我们就可以在主循环里更新时间信息了.幸运的是ggez已经提供了获取空闲时间的函数,我们只需要调用函数获取空闲时间并累加就好了:
#![allow(unused)] fn main() { // main.rs impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } // Run gameplay state system { let mut gss = GameplayStateSystem {}; gss.run_now(&self.world); } // Get and update time resource { let mut time = self.world.write_resource::<Time>(); time.delta += timer::delta(context); } Ok(()) } }
渲染系统
现在我们需要修改渲染系统的代码.获取可渲染组件的类型,根据据类型判断如果是静态的就渲染第一个图片就可以了,如果是动态组件就使用当前累计的空闲时间找到相应的图片渲染就可以了.
我们先把找图片的逻辑封装到一个函数get_image
里:
#![allow(unused)] fn main() { // rendering_system.rs impl RenderingSystem<'_> { //... pub fn get_image(&mut self, 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::new(self.context, image_path).expect("expected image") } } }
最后在运行函数中使用 get_image
函数(另外我们还需要在 SystemData
中定义Time
,再添加一些导入语句什么的就可以了).
#![allow(unused)] fn main() { // rendering_system.rs impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = ( Read<'a, Gameplay>, Read<'a, Time>, ReadStorage<'a, Position>, ReadStorage<'a, Renderable>, ); fn run(&mut self, data: Self::SystemData) { let (gameplay, time, positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(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 rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.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 = self.get_image(renderable, time.delta); //... } //... } } }
给盒子添加动画效果
现在我们已经学会怎么添加动画效果了,依葫芦画瓢也给盒子添加上动画效果吧.下面是我使用的素材,仅供参考,你也可以创建自己的素材.
总结
这节有点长,恭喜你成功看到了这里. 我们的游戏现在看起来像这样:
CODELINK: 点 这里获取完整实例代码.
声音和事件
本节我们将给游戏添加声效.简单说就是在某些场景下播放声音:
- 当角色碰到墙或障碍物时播放声音,让玩家知道不能穿越过去.
- 当角色把盒子推到了正确的地方时播放声音,让玩家知道"这么干就对了"
- 当角色把盒子推到了不正确的地方时播放声音,让玩家知道"这地儿不对"
根据ggez提供的功能实现播放声音并不难,我们需要解决的最大问题是确定在何时播放声音.
以把盒子推到了正确的地方为例.我们很可能不断的检测游戏状态系统中保存的盒子和目标点的信息,如果匹配上了就播放声效.但这样有个问题,我们的检测会每秒种执行多次,也就是会播放声效多次.但我们只想播放一次.当然也可以通过维护一些状态信息做到只播放一次,但我们并不想这么做.我们不想通过循环检测状态信息,而是使用响应式模型解决这个问题.也就是当某个动作发生时,可以发送一个时间,然后在其它地方就可以监听这个时间并做出响应.比如当玩家把盒子推到正确的地方时触发一个事件,在其它地方监听到这个事件了就播放相应的音效.而且这个事件系统还能用于解决其它问题.
实现事件系统
接下来看下怎么实现事件系统.我们不使用组件也不使用实体(虽然也可以用),像实现输入队列一样使用资源.我们需要编写把事件放入到队列中和从资源中获取事件的代码,另外我还需要编写根据事件类型执行相应操作的代码.
事件
接下来我们看下需要什么样的事件:
- 角色碰到障碍物事件- 这个事件是现成的可以在想移动却移动不成时在输入系统中触发这个事件
- 把盒子推到正确/不正确的地方事件 - 我们可以用一个事件表示这俩种情况,只需要用一个属性区分就好.深入点说就是我们可以做个实体移动事件,当我们接受到实体移动事件时获取移动实体的ID,并判断这个移动的实体是否是盒子,如果是就判断它是不是移动到了正确的地方 (这也是创建事件链的示例-根据一个事件生成另一个事件)
事件类型
接下来就开始考虑怎么实现事件了.我们使用enum定义多种事件类型.先前我们已经使用过枚举类型了,像渲染类型,盒子颜色.但这次我们将用到Rust枚举更高级的功能.枚举一个最有趣的功能是每种枚举都可以携带相应属性.
上代码:
#![allow(unused)] fn main() { // events.rs #[derive(Debug)] pub enum Event { // Fired when the player hits an obstacle like a wall PlayerHitObstacle, // Fired when an entity is moved EntityMoved(EntityMoved), // Fired when the box is placed on a spot BoxPlacedOnSpot(BoxPlacedOnSpot), } }
注意看第二个 EntityMoved
和第二个 BoxPlacedOnSpot
. 这些就是我们定义用来携带属性的结构体.代码是这样的:
#![allow(unused)] fn main() { // events.rs pub type EntityId = u32; #[derive(Debug)] pub struct EntityMoved { pub id: EntityId, } #[derive(Debug)] pub struct BoxPlacedOnSpot { pub is_correct_spot: bool, } }
Event queue resource
现在我们可以编写事件队列资源了.可以有很多系统往这个队列里发送数据,但只有一个系统(事件系统)从队列里消费数据.这是一个典型的多生产者单消费者模式.
#![allow(unused)] fn main() { // resources.rs #[derive(Default)] pub struct EventQueue { pub events: Vec<Event>, } }
跟原来一样,还是要注册下资源的:
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); world.insert(Time::default()); world.insert(EventQueue::default()); } }
事件发送
现在我们已经有了往队列里放事件的方法.接下来我们就创建俩个在input_system中用到的事件:EntityMoved和 PlayerHitObstacle.
#![allow(unused)] fn main() { // input_system.rs use crate::components::*; use crate::constants::*; use crate::events::{EntityMoved, Event}; use crate::resources::{EventQueue, Gameplay, InputQueue}; use ggez::event::KeyCode; use specs::{world::Index, Entities, Join, ReadStorage, System, Write, WriteStorage}; use std::collections::HashMap; pub struct InputSystem {} // System implementation impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, EventQueue>, Write<'a, InputQueue>, Write<'a, Gameplay>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let ( mut events, mut input_queue, mut gameplay, entities, mut positions, players, movables, immovables, ) = data; let mut to_move = Vec::new(); for (position, _player) in (&positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // ... // ... // 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(); events.events.push(Event::PlayerHitObstacle {}) } None => break, } } } } } } // We've just moved, so let's increase the number of moves if to_move.len() > 0 { gameplay.moves_count += 1; } // Now actually move what needs to be moved for (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } // Fire an event for the entity that just moved events.events.push(Event::EntityMoved(EntityMoved { id })); } } } }
为了读起来方便,这里省略了一些代码.其实我们就是在相应的地方添加了2行代码.
事件消费 - 事件系统
是时候添加处理消费事件的功能了,也就是事件系统.这个功能实现根据接受到的事件执行相应操作的逻辑.
接下来我们看下怎么处理每种类型的事件:
- Event::PlayerHitObstacle -> 播放相应音效
- Event::EntityMoved(EntityMoved { id }) -> 检查移动的实体是否是盒子,盒子是放到了正确的目标点还是放到了错误的目标点.
- Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot }) -> 播放相应音效.
#![allow(unused)] fn main() { // event_system.rs use crate::{ audio::AudioStore, components::*, events::{BoxPlacedOnSpot, EntityMoved, Event}, resources::EventQueue, }; use specs::{Entities, Join, ReadStorage, System, Write}; use std::collections::HashMap; pub struct EventSystem<'a> { pub context: &'a mut ggez::Context, } // System implementation impl<'a> System<'a> for EventSystem<'a> { // Data type SystemData = ( Write<'a, EventQueue>, Write<'a, AudioStore>, Entities<'a>, ReadStorage<'a, Box>, ReadStorage<'a, BoxSpot>, ReadStorage<'a, Position>, ); fn run(&mut self, data: Self::SystemData) { let (mut event_queue, mut audio_store, entities, boxes, box_spots, positions) = data; let mut new_events = Vec::new(); for event in event_queue.events.drain(..) { println!("New event: {:?}", event); match event { // play sound here audio_store.play_sound(self.context, &"wall".to_string()); } Event::EntityMoved(EntityMoved { id }) => { // An entity was just moved, check if it was a box and fire // more events if it's been moved on a spot. if let Some(the_box) = boxes.get(entities.entity(id)) { let box_spots_with_positions: HashMap<(u8, u8), &BoxSpot> = (&box_spots, &positions) .join() .map(|t| ((t.1.x, t.1.y), t.0)) .collect::<HashMap<_, _>>(); if let Some(box_position) = positions.get(entities.entity(id)) { // Check if there is a spot on this position, and if there // is if it's the correct or incorrect type if let Some(box_spot) = box_spots_with_positions.get(&(box_position.x, box_position.y)) { new_events.push(Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot: (box_spot.colour == the_box.colour), })); } } } } Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot }) => { // play sound here } } } event_queue.events.append(&mut new_events); } } }
Audio assets
现在已经添加好了事件,接下来我们开始添加声音素材.我们从这个 素材包里选了3个声音素材,你也可以使用自己的素材.
盒子放到正确地方时播放 这个
盒子放到不正确地方时播放这个
角色碰到障碍物时播放这个
把这些声音素材添加到resources文件夹下的sounds文件夹下:
.
├── resources
│ ├── images
│ │ ├── box_blue_1.png
│ │ ├── box_blue_2.png
│ │ ├── box_red_1.png
│ │ ├── box_red_2.png
│ │ ├── box_spot_blue.png
│ │ ├── box_spot_red.png
│ │ ├── floor.png
│ │ ├── player_1.png
│ │ ├── player_2.png
│ │ ├── player_3.png
│ │ └── wall.png
│ └── sounds
│ ├── correct.wav
│ ├── incorrect.wav
│ └── wall.wav
├── Cargo.lock
└── Cargo.toml
声音仓库
现在为了播放声音我们需要加载一些wav文件.为了避免每次播放声音都重新加载一次,我们需要创建一个声音仓库,在游戏开始时就把所有的声音文件加载好.
我们使用一个资源做为声音仓库:
#![allow(unused)] fn main() { // audio.rs #[derive(Default)] pub struct AudioStore { pub sounds: HashMap<String, audio::Source>, } }
像往常一样注册下资源:
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); world.insert(Time::default()); world.insert(EventQueue::default()); world.insert(AudioStore::default()); } }
接下来添加初始化仓库的代码:
#![allow(unused)] fn main() { // audio.rs pub fn initialize_sounds(world: &mut World, context: &mut Context) { let mut audio_store = world.write_resource::<AudioStore>(); let sounds = ["correct", "incorrect", "wall"]; for sound in sounds.iter() { let sound_name = sound.to_string(); let sound_path = format!("/sounds/{}.wav", sound_name); let sound_source = audio::Source::new(context, sound_path).expect("expected sound loaded"); audio_store.sounds.insert(sound_name, sound_source); } } }
播放音效
最后是在仓库中添加播放音效的功能:
#![allow(unused)] fn main() { // audio.rs impl AudioStore { pub fn play_sound(&mut self, context: &mut Context, sound: &String) { let _ = self .sounds .get_mut(sound) .expect("expected sound") .play_detached(context); } } }
然后在声音系统中执行播放音效操作:
#![allow(unused)] fn main() { // event_system.rs ); fn run(&mut self, data: Self::SystemData) { let (mut event_queue, mut audio_store, entities, boxes, box_spots, positions) = data; let mut new_events = Vec::new(); for event in event_queue.events.drain(..) { println!("New event: {:?}", event); match event { Event::PlayerHitObstacle => { // play sound here audio_store.play_sound(self.context, &"wall".to_string()); // ... } Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot }) => { // play sound here let sound = if is_correct_spot { "correct" } else { "incorrect" }; audio_store.play_sound(self.context, &sound.to_string()) } } } }
现在让我们运行程序,感受下声音效果吧!
CODELINK: 点 这里获取示例完整代码.
批量渲染
你或许已经感觉到了,我们的游戏还有点操作卡顿.接下来我们会先添加一个FPS计算器,看下游戏的渲染速度.FPS就是Frames Per Second的首字母缩写,也就是每秒钟的渲染帧数.我们的目标是60FPS,也就是每秒钟渲染60帧.
FPS计算器
添加FPS计算器,可以分为2步:
- 获取并计算FPS值
- 把FPS值渲染在屏幕上
对于第1步幸运的是ggez已经帮我们实现了-可以看 这里. 至于第2步我们先前已经在渲染系统中渲染过文本,在那里获取FPS值渲染就好了.上代码:
#![allow(unused)] fn main() { // rendering_system.rs fn run(&mut self, data: Self::SystemData) { ... // Render any text self.draw_text(&gameplay.state.to_string(), 525.0, 80.0); self.draw_text(&gameplay.moves_count.to_string(), 525.0, 100.0); let fps = format!("FPS: {:.0}", timer::fps(self.context)); self.draw_text(&fps, 525.0, 120.0); ... } }
运行游戏试着玩一下,你会看到FPS比我们期望的60低的多,在我的电脑上是20到30,在你的电脑上可能会多点也可能少点,毕竟我们的电脑配置不一样.
什么造成了FPS降低呢?
你是不是也很好奇是什么造成了这么低的FPS呢?我们的游戏这么简单,操作逻辑什么的都不复杂啊,况且使用的实体,组件什么的也不多,FPS怎么就这么低呢?要弄清这个需要深入了解下渲染系统当前是怎么工作的.
目前渲染每一个实体都需要取获取对应的图片然后渲染,也就是如果渲染20块地板就需要加载地板图片20次,执行渲染操作20次.这样太浪费性能,也是造成FPS底的主要原因.
怎么解决呢?我们可以使用批量渲染的技术解决这个问题.使用这种技术我们就可以一个图片只加载一次,然后渲染到20个不同地方,不但只需要加载一次连一个图片也只需要执行一次渲染操作.这样就可以大幅度提高性能.还需要说明的是:有些引擎会在内部实现批量渲染,但是ggez还没有,所以我们需要自己特别关注下这块.
批量渲染
要实现批量渲染,我们需要:
- 对于每个实体我们需要获取相应的图片和DrawParams (用于告诉ggez在什么地方渲染)
- 使用一个合适的结构保存所有的 image, DrawParams
- 在一次渲染操作中遍历所有的image, DrawParams并渲染
在编写渲染代码前,我们需要对集合进行分钟和排序操作,因此可以引入crate itertools
.当然我们也可以自己实现相关的功能,但没必要重复制造轮子是不?把itertools
添加到项目依赖中:
// Cargo.toml
[dependencies]
ggez = "0.7"
glam = { version = "0.20.0", features = ["mint"] }
specs = { version = "0.17.0", features = ["specs-derive"] }
在渲染系统中导入:
#![allow(unused)] fn main() { // rendering_system.rs use itertools::Itertools; }
还记得在动画章节编写的为每一帧获取渲染图片的get_image
函数吧,这里依然可以重用,只是要改成返回图片的路径而不是直接加载图片.
#![allow(unused)] fn main() { // rendering_system.rs pub fn get_image(&mut self, 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. - 值也是
HashMap
类型, 这里的第二个键 (String
) 是图片的路径 - 后面这个值是
Vec<DrawParam>
类型,用于存储渲染图片时用到的信息
现在让我们编写处理这个hash map的函数rendering_batches
:
#![allow(unused)] fn main() { // rendering_system.rs fn run(&mut self, data: Self::SystemData) { ... // Get all the renderables with their positions. let rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); 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 = self.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; // Add to rendering batches let draw_param = DrawParam::new().dest(Vec2::new(x, y)); rendering_batches .entry(z) .or_default() .entry(image_path) .or_default() .push(draw_param); } ... } }
最后就可以真正批量渲染了.批量渲染就不能用先前的API draw(image)
了,那怎么渲染呢?好在ggez提供了批量API - SpriteBatch. 注意sorted_by
的地方, 我们使用了itertools
.
#![allow(unused)] fn main() { // rendering_system.rs fn run(&mut self, data: Self::SystemData) { ... // 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::new(self.context, image_path).expect("expected image"); let mut sprite_batch = SpriteBatch::new(image); for draw_param in draw_params.iter() { sprite_batch.add(*draw_param); } graphics::draw(self.context, &sprite_batch, graphics::DrawParam::new()) .expect("expected render"); } } ... } }
这就搞定了!再运行下游戏你会发现已经达到60FPS了,这顺滑!
CODELINK: 点 这里查看示例完整代码.