Rust 项目实战:Flappy Bird 游戏
- Rust 项目实战:Flappy Bird 游戏
- 理解 Game loop
- 开发库:bracket-lib
- bracket-terminal
- Codepage 437
- 导入 bracket-lib
- 创建游戏
- 游戏的模式
- 添加玩家
- 添加障碍
- 最终效果
- 项目源码
Rust 项目实战:Flappy Bird 游戏
参考视频:https://www.bilibili.com/video/BV1vM411J74S
理解 Game loop
为了让游戏流畅、顺滑的运行,需要 Game loop。
Game loop:
- 初始化窗口、图形和其他资源
- 每当屏幕刷新(通常为每秒 30 次、60 次、…),它都会运行
- 每次通过循环,它都会调用游戏的 tick() 函数
开发库:bracket-lib
bracket-lib 是一个 Rust 游戏编程库,其中包含了随机数生成、几何、路径寻找、颜色处理、常用算法等库。
bracket-lib 作为简单的教学工具,抽象了游戏开发很多复杂的东西,但保留了相关的概念。
bracket-terminal
bracket-terminal 是 bracket-lib 中负责显示的部分。它提供了模拟控制台,可与多种渲染平台(OpenGL、Vulkan、Metal、Web Assembly)配合,还支持 Sprites 和原生 OpenGL 开发。
在游戏开发当中,Sprites 一般指一个实体,但是跟我们平常开发软件说的那种“实体”不一样,它是直接让玩家看到、控制甚至接触的物体,称之为“精灵”,传统意义上是指可见操作物体,当然现在也有触发精灵之类的不可见操作物体。
Codepage 437
Codepage 437 是 IBM 扩展 ASCII 字符集。
- 来自 Dos PC 上的字符,用于终端输出,除了字母和数字,还提供了一些符号。
- bracket-lib 会把字符翻译成图形 Sprites 并提供一个有限的字符集,字符所展示的是相应的图片。
导入 bracket-lib
项目的 Cargo.toml 中的 [dependencies] 部分插入:
bracket-lib = "~0.8.7"
创建游戏
main.rs:
use bracket_lib::prelude::*;struct State {}impl GameState for State {fn tick(&mut self, ctx: &mut BTerm) {ctx.cls();ctx.print(1, 1, "Hello, Brackets Terminal!");}
}fn main() -> BError {let context = BTermBuilder::simple80x50().with_title("Flappy Bird").build()?;main_loop(context, State {})
}
结构体 State 保存游戏的状态。为 State 实现来自 bracket_lib 的 GameState trait。
需要实现一个 tick 方法,ctx 是上下文,指的是游戏窗口。tick 方法内,先使用 cls() 方法清理屏幕,再使用 print 方法,在屏幕的 (1, 1) 坐标上打印文本 Hello, Brackets Terminal!。
游戏运行有可能出错,bracket_lib 提供了一个叫做 BError 的 Rusult 用来表示错误。
在 main 函数中,我们使用构建者模式创建一个 80 * 50 的窗口,标题为 Flappy Bird。因为构建可能出错,在 build() 函数后使用 ? 进行处理。
main_loop 就是游戏的主循环,需要传入 context 和状态 State。因为 main_loop 是需要返回的,所以后面没有分号。
运行程序:
游戏的模式
游戏通常在不同的模式中运行,每种模式会明确游戏在当前的 tick() 中应该做什么。
我们这个游戏需要 3 种模式:
- 菜单
- 游戏中
- 结束
我们建立一个枚举 GameMode 表示这 3 种模式。
enum GameMode {Menu,Playing,End,
}
我们需要把模式存储在状态中,修改 State 的定义:
struct State {mode: GameMode,
}
再为 State 实现一个关联函数 new(),游戏的初始状态是菜单。
impl State {fn new() -> Self {State {mode: GameMode::Menu,}}
}
修改 tick() 方法,根据状态的当前模式,执行不同的函数,这里使用 match 表达式。
fn tick(&mut self, ctx: &mut BTerm) {match self.mode {GameMode::Menu => self.main_menu(ctx),GameMode::Playing => self.play(ctx),GameMode::End => self.dead(ctx),}}
这里的 main_menu、play、dead 函数都还没有在 State 中实现。
这里直接给出 State 中 4 个新函数的实现代码:
impl State {fn new() -> Self {State {mode: GameMode::Menu,}}fn main_menu(&mut self, ctx: &mut BTerm) {ctx.cls();ctx.print_centered(5, "Welcome to Flappy Bird");ctx.print_centered(8, "(P) Play Game");ctx.print_centered(9, "(Q) Quit Game");if let Some(key) = ctx.key {match key {VirtualKeyCode::P => self.restart(),VirtualKeyCode::Q => ctx.quitting = true,_ => {}}}}fn play(&mut self, ctx: &mut BTerm) {// TODOself.mode = GameMode::End;}fn dead(&mut self, ctx: &mut BTerm) {ctx.cls();ctx.print_centered(5, "You are dead!");ctx.print_centered(8, "(P) Play Again");ctx.print_centered(9, "(Q) Quit Game");if let Some(key) = ctx.key {match key {VirtualKeyCode::P => self.restart(),VirtualKeyCode::Q => ctx.quitting = true,_ => {}}}}fn restart(&mut self) {self.mode = GameMode::Playing;}
}
因为 State 有了新成员变量,之前的 main_loop 中的 State {} 不行了,修改 main 函数:
main_loop(context, State::new())
运行程序,游戏状态首先是 GameMode::Menu。在 tick() 函数中进行匹配,执行 main_menu 函数,显示菜单:
按下 P 开始游戏,执行 restart 函数,将游戏状态变为 GameMode::Playing,在 tick() 函数中进行匹配,执行 play 函数。
因为我们还没有实现 play 函数中应该有的内容,play 函数只是将游戏状态变为 GameMode::End。
再次在 tick() 函数中进行匹配,执行 dead 函数,显示新的界面:
如果在结束界面按下 P,还是显示相同的界面。
如果在结束界面按下 Q,则退出游戏,窗口被关闭。
添加玩家
创建一个结构体 Player 表示玩家:
struct Player {x: i32,y: i32,velocity: f32, // 垂直方向的加速度,向下为正
}
x、y 是玩家在窗口中的坐标。
Player 相关方法:
impl Player {fn new(x: i32, y: i32) -> Self {Player { x, y, velocity: 0.0 }}fn render(&mut self, ctx: &mut BTerm) {ctx.set(0, self.y, YELLOW, BLACK, to_cp437('@'))}fn gravity_and_move(&mut self) {if self.velocity < 2.0 {self.velocity += 0.2;}self.x += 1;self.y += self.velocity as i32;if self.y < 0 {self.y = 0;}}fn flap(&mut self) {self.velocity = -2.0;}
}
render 方法用于渲染。设置背景色为黑色,在 (0, y) 坐标渲染一个黄色的 @ 字符。
gravity_and_move 方法用于模拟玩家的重力,当 velocity < 2.0 时,velocity 会越来越大,每次增加 0.2。然后,玩家的坐标 (x, y) 发生改变,在水平方向上每次前进 1 格,在竖直方向上相当于 y = y + a * t。特殊的,要判断 y < 0 的情况,也就是说,玩家不能超出窗口的上边缘。
flap 方法用于模拟按下空格的情况,每次按下空格,玩家都会向上扑腾一下。实际上就是将玩家的 velocity 修改为 -2.0,负数表示向上的加速度。
我们需要在状态中添加 Player 成员变量:
struct State {player: Player,frame_time: f32, // 游戏累计时间mode: GameMode,
}
另外增加了一个 frame_time 成员变量,表示游戏运行的累计时间。
对应的,State 的 new 函数和 restart 函数都需要做修改:
fn new() -> Self {State {player: Player::new(5, 25),frame_time: 0.0,mode: GameMode::Menu,}}// ...fn restart(&mut self) {self.player = Player::new(5, 25);self.frame_time = 0.0;self.mode = GameMode::Playing;}
我们默认玩家出生在 (5, 25) 坐标。
我们向程序中添加一些常量:
/// 游戏屏幕宽度
const SCREEN_WIDTH: i32 = 80;
/// 游戏屏幕高度
const SCREEN_HEIGHT: i32 = 50;
/// 游戏单位时间,每隔 75 ms 做一些事情
const FRAME_DURATION: f32 = 75.0;
接下来完善 play 函数的逻辑:
fn play(&mut self, ctx: &mut BTerm) {// 清空屏幕,并设置屏幕的背景颜色ctx.cls_bg(NAVY);// frame_time_ms 记录了每次调用 tick() 所经过的时间self.frame_time += ctx.frame_time_ms;// 向前移动并且重力增加if self.frame_time > FRAME_DURATION {self.frame_time = 0.0;self.player.gravity_and_move();}// 用户点击了空格,往上飞if let Some(VirtualKeyCode::Space) = ctx.key {self.player.flap();}// 渲染self.player.render(ctx);ctx.print(0, 0, "Press SPACE to flap");// 如果 y 大于游戏屏幕高度,视为坠地,游戏结束if self.player.y > SCREEN_HEIGHT {self.mode = GameMode::End;}}
运行程序,进入游戏:
当我们按下空格时,小鸟会向上移动。否则,小鸟受重力作用加速向下移。若小鸟触碰到窗口底部,则游戏失败。
添加障碍
结构体 Obstacle 表示障碍:
struct Obstacle {x: i32, // 横坐标gap_y: i32, // 中间的空隙坐标size: i32, // 空隙大小
}
游戏中的障碍如下图所示:
Obstacle 的 new 函数:
pub fn new(x: i32, score: i32) -> Self {let mut random = RandomNumberGenerator::new();Obstacle {x,gap_y: random.range(10, 40), // [10, 40)size: i32::max(2, 20 - score), // 积分越多,洞越窄}}
障碍中间的空隙坐标使用随机数生成。障碍空隙大小取决于玩家积分,积分越多,洞越窄。
Obstacle 的 render 函数:
fn render(&mut self, ctx: &mut BTerm, player_x: i32) {let screen_x = self.x - player_x; // 屏幕空间的横坐标let half_size = self.size / 2;// 障碍物的上半部分for y in 0..self.gap_y - half_size {ctx.set(screen_x, y, RED, BLACK, to_cp437('|'));}// 障碍物的下半部分for y in self.gap_y + half_size..SCREEN_HEIGHT {ctx.set(screen_x, y, RED, BLACK, to_cp437('|'))}}
障碍的横坐标是 self.x,玩家的横坐标是 player_x,它们都是世界空间下的横坐标,可以是无限大。而屏幕空间是有限的,所以计算 self.x - player_x 得到的相对坐标才是障碍在屏幕中的横坐标。
Obstacle 的 hit_obstacle 函数:
fn hit_obstacle(&self, player: &Player) -> bool {let half_size = self.size / 2;// 玩家的 x 坐标和障碍的坐标是否一样let does_x_match = player.x == self.x;// 是否在障碍的上半部分的坐标范围内let player_above_gap = player.y < self.gap_y - half_size;// 是否在障碍的下半部分的坐标范围内let player_below_gap = player.y > self.gap_y + half_size;does_x_match && (player_above_gap || player_below_gap)}
只有当玩家的 x 坐标和障碍物的坐标一样,且玩家在障碍的上半部分或下半部分,才视为玩家撞到了障碍物。
再向 State 中添加 2 个成员变量:
struct State {player: Player,frame_time: f32, // 游戏累计时间mode: GameMode,obstacle: Obstacle,score: i32,
}
对应的也要修改其构造函数、dead 函数和 restart 函数:
fn new() -> Self {State {player: Player::new(5, 25),frame_time: 0.0,mode: GameMode::Menu,obstacle: Obstacle::new(SCREEN_WIDTH, 0),score: 0,}}// ...fn dead(&mut self, ctx: &mut BTerm) {ctx.cls();ctx.print_centered(5, "You are dead!");ctx.print_centered(6, &format!("You earned {} points", self.score));ctx.print_centered(8, "(P) Play Again");ctx.print_centered(9, "(Q) Quit Game");if let Some(key) = ctx.key {match key {VirtualKeyCode::P => self.restart(),VirtualKeyCode::Q => ctx.quitting = true,_ => {}}}}fn restart(&mut self) {self.player = Player::new(5, 25);self.frame_time = 0.0;self.mode = GameMode::Playing;self.obstacle = Obstacle::new(SCREEN_WIDTH, 0);self.score = 0;}
再修改 play 函数,添加障碍和积分的相关代码:
fn play(&mut self, ctx: &mut BTerm) {// 清空屏幕,并设置屏幕的背景颜色ctx.cls_bg(NAVY);// frame_time_ms 记录了每次调用 tick() 所经过的时间self.frame_time += ctx.frame_time_ms;// 向前移动并且重力增加if self.frame_time > FRAME_DURATION {self.frame_time = 0.0;self.player.gravity_and_move();}// 用户点击了空格,往上飞if let Some(VirtualKeyCode::Space) = ctx.key {self.player.flap();}// 渲染self.player.render(ctx);ctx.print(0, 0, "Press SPACE to flap");ctx.print(0, 1, &format!("Score: {}", self.score));// 渲染障碍物self.obstacle.render(ctx, self.player.x);// 判断是否越过障碍物if self.player.x > self.obstacle.x {self.score += 1;// 渲染新的障碍物self.obstacle = Obstacle::new(self.player.x + SCREEN_WIDTH, self.score);}// 如果 y 大于游戏屏幕高度,视为坠地,或者撞到障碍物,则游戏结束if self.player.y > SCREEN_HEIGHT || self.obstacle.hit_obstacle(&self.player) {self.mode = GameMode::End;}}
最终效果
开始菜单:
游戏界面:
结束界面:
项目源码
GitHub:https://github.com/UestcXiye/Flappy-Bird