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