Rust + Bevy 实现的 Flappy Bird 游戏
简介
一个使用 bevy 引擎复刻的 Flappy Bird 经典小游戏。
通过该项目我们可以学到:bevy 的自定义组件,自定义插件,自定义资源,sprite 的旋转,sprite 的移动,sprite sheet 动画的定义使用,状态管理,等内容…
简单介绍一下包含的内容:
- 游戏状态管理 Menu、InGame、Paused、GameOver。
- 小鸟碰撞检测。
- 地面移动。
- 小鸟飞翔动画。
- 小鸟飞行方向变化。
- 小鸟重力系统。
- 障碍物随机生成。
通过空格向上飞行。
按 P 暂停游戏,按 R 恢复游戏。
代码结构
| · |
| ├── assets/ |
| │ ├──audios/ |
| │ ├──fonts/ |
| │ └──images/ |
| ├── src/ |
| │ ├── build.rs |
| │ ├── components.rs |
| │ ├── constants.rs |
| │ ├── main.rs |
| │ ├── obstacle.rs |
| │ ├── player.rs |
| │ ├── resource.rs |
| │ └── state.rs |
| ├── Cargo.lock |
| └── Cargo.toml |
- assets/audios 声音资源文件。
- assets/fonts 字体资源文件。
- assets/images 图片资源文件。
- build.rs 构建之前执行的脚本文件。
- components.rs 游戏组件定义。
- constants.rs 负责存储游戏中用到的常量。
- main.rs 负责游戏的逻辑、插件交互、等内容。
- obstacle.rs 障碍物生成、初始化。
- player.rs 玩家角色插件,生成、移动、键盘处理的实现。
- resource.rs 游戏资源定义。
- state.rs 游戏状态管理。
build.rs
| use std::{ |
| env, fs, |
| path::{Path, PathBuf}, |
| }; |
| |
| const COPY_DIR: &'static str = "assets"; |
| |
| |
| fn copy_dir<P, Q>(from: P, to: Q) |
| where |
| P: AsRef<Path>, |
| Q: AsRef<Path>, |
| { |
| let to = to.as_ref().to_path_buf(); |
| |
| for path in fs::read_dir(from).unwrap() { |
| let path = path.unwrap().path(); |
| let to = to.clone().join(path.file_name().unwrap()); |
| |
| if path.is_file() { |
| fs::copy(&path, to).unwrap(); |
| } else if path.is_dir() { |
| if !to.exists() { |
| fs::create_dir(&to).unwrap(); |
| } |
| |
| copy_dir(&path, to); |
| } else { |
| } |
| } |
| } |
| |
| fn main() { |
| |
| let out = env::var("PROFILE").unwrap(); |
| let out = PathBuf::from(format!("target/{}/{}", out, COPY_DIR)); |
| |
| |
| if out.exists() { |
| fs::remove_dir_all(&out).unwrap(); |
| } |
| |
| |
| fs::create_dir(&out).unwrap(); |
| |
| |
| copy_dir(COPY_DIR, &out); |
| } |
components.rs
| use bevy::{ |
| prelude::Component, |
| time::{Timer, TimerMode}, |
| }; |
| |
| |
| #[derive(Component)] |
| pub struct Player; |
| |
| |
| #[derive(Component)] |
| pub struct PlayerAnimationTimer(pub Timer); |
| |
| impl Default for PlayerAnimationTimer { |
| fn default() -> Self { |
| Self(Timer::from_seconds(0.1, TimerMode::Repeating)) |
| } |
| } |
| |
| |
| #[derive(Component)] |
| pub struct Obstacle; |
| |
| |
| #[derive(Component)] |
| pub struct Movable { |
| |
| pub need_rotation: bool, |
| } |
| |
| impl Default for Movable { |
| fn default() -> Self { |
| Self { |
| need_rotation: false, |
| } |
| } |
| } |
| |
| |
| #[derive(Component)] |
| pub struct Velocity { |
| pub x: f32, |
| pub y: f32, |
| } |
| |
| impl Default for Velocity { |
| fn default() -> Self { |
| Self { x: 0., y: 0. } |
| } |
| } |
| |
| |
| #[derive(Component)] |
| pub struct DisplayScore; |
| |
| |
| #[derive(Component)] |
| pub struct DisplayMenu; |
| |
| |
| #[derive(Component)] |
| pub struct Ground(pub f32); |
| |
| |
| #[derive(Component)] |
| pub struct DisplayGameOver; |
constants.rs
| |
| pub const BIRD_IMG_PATH: &str = "images/bird_columns.png"; |
| |
| pub const BIRD_IMG_SIZE: (f32, f32) = (34., 24.); |
| |
| pub const BIRD_ANIMATION_LEN: usize = 3; |
| |
| pub const WINDOW_WIDTH: f32 = 576.; |
| pub const WINDOW_HEIGHT: f32 = 624.; |
| |
| |
| pub const BACKGROUND_IMG_PATH: &str = "images/background.png"; |
| |
| pub const BACKGROUND_IMG_SIZE: (f32, f32) = (288., 512.); |
| |
| pub const GROUND_IMG_PATH: &str = "images/ground.png"; |
| |
| pub const GROUND_IMG_SIZE: (f32, f32) = (336., 112.); |
| |
| pub const GROUND_ITEM_SIZE: f32 = 48.; |
| |
| pub const PIPE_IMG_PATH: &str = "images/pipe.png"; |
| |
| pub const PIPE_IMG_SIZE: (f32, f32) = (52., 320.); |
| |
| pub const FLAY_AUDIO_PATH: &str = "audios/wing.ogg"; |
| |
| pub const POINT_AUDIO_PATH: &str = "audios/point.ogg"; |
| |
| pub const DIE_AUDIO_PATH: &str = "audios/die.ogg"; |
| |
| pub const HIT_AUDIO_PATH: &str = "audios/hit.ogg"; |
| |
| pub const KENNEY_FUTURE_FONT_PATH: &str = "fonts/KenneyFuture.ttf"; |
| |
| |
| pub const SPAWN_OBSTACLE_TICK: f32 = 4.; |
| |
| pub const PLAYER_X_MAX_VELOCITY: f32 = 48.; |
| |
| pub const PLAYER_Y_MAX_UP_VELOCITY: f32 = 20.; |
| |
| pub const PLAYER_Y_UP_PIXEL: f32 = 10.; |
| |
| pub const PLAYER_Y_MAX_VELOCITY: f32 = 200.; |
| |
| pub const GRAVITY_VELOCITY: f32 = 80.; |
| |
| pub const TIME_STEP: f32 = 1. / 60.; |
| |
| |
| pub const GAP_MAX: f32 = 300.; |
| |
| pub const GAP_MIN: f32 = 50.; |
main.rs
| use bevy::{ |
| prelude::*, |
| sprite::collide_aabb::collide, |
| window::{Window, WindowPlugin, WindowPosition}, |
| }; |
| use obstacle::ObstaclePlugin; |
| |
| use components::{DisplayScore, Ground, Movable, Obstacle, Player, PlayerAnimationTimer, Velocity}; |
| use constants::*; |
| use player::PlayerPlugin; |
| use resource::{GameData, StaticAssets, WinSize}; |
| use state::{GameState, StatesPlugin}; |
| |
| mod components; |
| mod constants; |
| mod obstacle; |
| mod player; |
| mod resource; |
| mod state; |
| |
| fn main() { |
| App::new() |
| .add_state::<GameState>() |
| .insert_resource(ClearColor(Color::rgb_u8(205, 201, 201))) |
| .add_plugins(DefaultPlugins.set(WindowPlugin { |
| primary_window: Some(Window { |
| title: "Flappy Bird".to_owned(), |
| resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(), |
| position: WindowPosition::At(IVec2::new(2282, 0)), |
| resizable: false, |
| ..Default::default() |
| }), |
| ..Default::default() |
| })) |
| .add_system(system_startup.on_startup()) |
| .add_plugin(StatesPlugin) |
| .add_plugin(PlayerPlugin) |
| .add_plugin(ObstaclePlugin) |
| .add_systems( |
| ( |
| score_display_update_system, |
| player_animation_system, |
| player_score_system, |
| movable_system, |
| ground_move_system, |
| player_collision_check_system, |
| ) |
| .in_set(OnUpdate(GameState::InGame)), |
| ) |
| .add_system(bevy::window::close_on_esc) |
| .run(); |
| } |
| |
| |
| fn player_collision_check_system( |
| win_size: Res<WinSize>, |
| static_assets: Res<StaticAssets>, |
| audio_player: Res<Audio>, |
| mut next_state: ResMut<NextState<GameState>>, |
| obstacle_query: Query<(Entity, &Transform), With<Obstacle>>, |
| player_query: Query<(Entity, &Transform), With<Player>>, |
| ) { |
| let player_result = player_query.get_single(); |
| match player_result { |
| Ok((_, player_tf)) => { |
| let mut is_collision = false; |
| |
| if player_tf.translation.y >= win_size.height / 2. |
| || player_tf.translation.y <= -(win_size.height / 2. - GROUND_IMG_SIZE.1) |
| { |
| is_collision = true; |
| } |
| |
| for (_, obstacle_tf) in obstacle_query.iter() { |
| let collision = collide( |
| player_tf.translation, |
| Vec2 { |
| x: BIRD_IMG_SIZE.0, |
| y: BIRD_IMG_SIZE.1, |
| }, |
| obstacle_tf.translation, |
| Vec2 { |
| x: PIPE_IMG_SIZE.0, |
| y: PIPE_IMG_SIZE.1, |
| }, |
| ); |
| if let Some(_) = collision { |
| is_collision = true; |
| break; |
| } |
| } |
| |
| if is_collision { |
| |
| audio_player.play(static_assets.hit_audio.clone()); |
| audio_player.play(static_assets.die_audio.clone()); |
| next_state.set(GameState::GameOver); |
| } |
| } |
| _ => (), |
| } |
| } |
| |
| |
| fn player_score_system( |
| mut commands: Commands, |
| mut game_data: ResMut<GameData>, |
| static_assets: Res<StaticAssets>, |
| audio_player: Res<Audio>, |
| obstacle_query: Query<(Entity, &Transform), With<Obstacle>>, |
| player_query: Query<(Entity, &Transform), With<Player>>, |
| ) { |
| let player_result = player_query.get_single(); |
| match player_result { |
| Ok((_, player_tf)) => { |
| let mut need_add_score = false; |
| for (entity, obstacle_tf) in obstacle_query.iter() { |
| |
| if player_tf.translation.x - BIRD_IMG_SIZE.0 / 2. |
| > obstacle_tf.translation.x + PIPE_IMG_SIZE.0 / 2. |
| { |
| |
| need_add_score = true; |
| commands.entity(entity).despawn(); |
| } |
| } |
| |
| if need_add_score { |
| |
| game_data.add_score(); |
| audio_player.play(static_assets.point_audio.clone()); |
| game_data.call_obstacle_spawn(); |
| } |
| } |
| _ => (), |
| } |
| } |
| |
| |
| |
| |
| fn movable_system( |
| mut query: Query<(&mut Transform, &Velocity, &Movable), (With<Movable>, With<Velocity>)>, |
| ) { |
| for (mut transform, velocity, movable) in query.iter_mut() { |
| let x = velocity.x * TIME_STEP; |
| let y = velocity.y * TIME_STEP; |
| transform.translation.x += x; |
| transform.translation.y += y; |
| |
| if movable.need_rotation { |
| if velocity.y > 0. { |
| transform.rotation = Quat::from_rotation_z(velocity.y / PLAYER_Y_MAX_UP_VELOCITY); |
| } else { |
| transform.rotation = Quat::from_rotation_z(velocity.y / PLAYER_Y_MAX_VELOCITY); |
| }; |
| } |
| } |
| } |
| |
| |
| fn ground_move_system(mut query: Query<(&mut Transform, &mut Ground)>) { |
| let result = query.get_single_mut(); |
| match result { |
| Ok((mut transform, mut ground)) => { |
| ground.0 += 1.; |
| transform.translation.x = -ground.0; |
| ground.0 = ground.0 % GROUND_ITEM_SIZE; |
| } |
| _ => (), |
| } |
| } |
| |
| |
| fn player_animation_system( |
| time: Res<Time>, |
| mut query: Query<(&mut PlayerAnimationTimer, &mut TextureAtlasSprite)>, |
| ) { |
| for (mut timer, mut texture_atlas_sprite) in query.iter_mut() { |
| timer.0.tick(time.delta()); |
| if timer.0.just_finished() { |
| let next_index = (texture_atlas_sprite.index + 1) % BIRD_ANIMATION_LEN; |
| texture_atlas_sprite.index = next_index; |
| } |
| } |
| } |
| |
| |
| fn score_display_update_system( |
| game_data: Res<GameData>, |
| mut query: Query<&mut Text, With<DisplayScore>>, |
| ) { |
| for mut text in &mut query { |
| text.sections[1].value = game_data.get_score().to_string(); |
| } |
| } |
| |
| fn system_startup( |
| mut commands: Commands, |
| asset_server: Res<AssetServer>, |
| mut texture_atlases: ResMut<Assets<TextureAtlas>>, |
| windows: Query<&Window>, |
| ) { |
| commands.spawn(Camera2dBundle::default()); |
| |
| let game_data = GameData::new(); |
| commands.insert_resource(game_data); |
| |
| let window = windows.single(); |
| let (window_w, window_h) = (window.width(), window.height()); |
| let win_size = WinSize { |
| width: window_w, |
| height: window_h, |
| }; |
| commands.insert_resource(win_size); |
| |
| let player_handle = asset_server.load(BIRD_IMG_PATH); |
| |
| |
| let texture_atlas = |
| TextureAtlas::from_grid(player_handle, Vec2::from(BIRD_IMG_SIZE), 1, 3, None, None); |
| let player = texture_atlases.add(texture_atlas); |
| |
| let background = asset_server.load(BACKGROUND_IMG_PATH); |
| let pipe = asset_server.load(PIPE_IMG_PATH); |
| let ground = asset_server.load(GROUND_IMG_PATH); |
| let fly_audio = asset_server.load(FLAY_AUDIO_PATH); |
| let die_audio = asset_server.load(DIE_AUDIO_PATH); |
| let point_audio = asset_server.load(POINT_AUDIO_PATH); |
| let hit_audio = asset_server.load(HIT_AUDIO_PATH); |
| let kenney_future_font = asset_server.load(KENNEY_FUTURE_FONT_PATH); |
| |
| let static_assets = StaticAssets { |
| player, |
| background, |
| pipe, |
| ground, |
| fly_audio, |
| die_audio, |
| point_audio, |
| hit_audio, |
| kenney_future_font, |
| }; |
| commands.insert_resource(static_assets); |
| |
| let (background_w, background_h) = BACKGROUND_IMG_SIZE; |
| let (ground_w, ground_h) = GROUND_IMG_SIZE; |
| commands.spawn(SpriteBundle { |
| texture: asset_server.load(BACKGROUND_IMG_PATH), |
| sprite: Sprite { |
| custom_size: Some(Vec2 { |
| x: background_w * 2., |
| y: background_h, |
| }), |
| ..Default::default() |
| }, |
| transform: Transform { |
| translation: Vec3 { |
| x: 0., |
| y: ground_h / 2., |
| z: 1., |
| }, |
| ..Default::default() |
| }, |
| ..Default::default() |
| }); |
| |
| commands.spawn(( |
| SpriteBundle { |
| texture: asset_server.load(GROUND_IMG_PATH), |
| sprite: Sprite { |
| custom_size: Some(Vec2 { |
| x: ground_w * 2., |
| y: ground_h, |
| }), |
| ..Default::default() |
| }, |
| transform: Transform { |
| translation: Vec3 { |
| x: 0., |
| y: window_h / 2. - background_h - ground_h / 2., |
| z: 4., |
| }, |
| ..Default::default() |
| }, |
| |
| ..Default::default() |
| }, |
| Ground(GROUND_ITEM_SIZE), |
| )); |
| } |
obstacle.rs
| use rand::{thread_rng, Rng}; |
| use std::time::Duration; |
| |
| use crate::{ |
| components::{Movable, Obstacle, Velocity}, |
| constants::{ |
| BACKGROUND_IMG_SIZE, GAP_MAX, GAP_MIN, GROUND_IMG_SIZE, PIPE_IMG_SIZE, |
| PLAYER_X_MAX_VELOCITY, SPAWN_OBSTACLE_TICK, |
| }, |
| resource::{GameData, StaticAssets, WinSize}, |
| state::GameState, |
| }; |
| |
| use bevy::{ |
| prelude::{ |
| Commands, Entity, IntoSystemAppConfig, IntoSystemConfig, OnEnter, OnUpdate, Plugin, Query, |
| Res, ResMut, Transform, Vec3, With, |
| }, |
| sprite::{Sprite, SpriteBundle}, |
| time::common_conditions::on_timer, |
| }; |
| |
| |
| pub struct ObstaclePlugin; |
| |
| impl Plugin for ObstaclePlugin { |
| fn build(&self, app: &mut bevy::prelude::App) { |
| app.add_system(obstacle_init_system.in_schedule(OnEnter(GameState::InGame))) |
| .add_system( |
| spawn_obstacle_system |
| .run_if(on_timer(Duration::from_secs_f32(0.2))) |
| .in_set(OnUpdate(GameState::InGame)), |
| ); |
| } |
| } |
| |
| |
| fn obstacle_init_system( |
| mut commands: Commands, |
| static_assets: Res<StaticAssets>, |
| win_size: Res<WinSize>, |
| game_data: Res<GameData>, |
| query: Query<Entity, With<Obstacle>>, |
| ) { |
| let count = query.iter().count(); |
| if count >= 4 { |
| return; |
| } |
| |
| let mut rng = thread_rng(); |
| |
| let x = win_size.width / 2. + PIPE_IMG_SIZE.0 / 2.; |
| |
| let center_y = (win_size.height - BACKGROUND_IMG_SIZE.1) / 2.; |
| |
| let reasonable_y_max = win_size.height / 2. - 100.; |
| let reasonable_y_min = -(win_size.height / 2. - 100. - GROUND_IMG_SIZE.1); |
| |
| let size = SPAWN_OBSTACLE_TICK * PLAYER_X_MAX_VELOCITY; |
| |
| for i in 0..2 { |
| let x = x - PIPE_IMG_SIZE.0 - size * i as f32; |
| |
| |
| let point_y = rng.gen_range(reasonable_y_min..reasonable_y_max); |
| let half_distance = (center_y - point_y).abs() / 2.; |
| |
| |
| let score = game_data.get_score(); |
| let max = GAP_MAX - score as f32 / 10.; |
| |
| |
| let max = max.max(GAP_MIN); |
| let min = GAP_MIN; |
| let gap = rng.gen_range(min.min(max)..min.max(max)); |
| let rand_half_gap = gap * rng.gen_range(0.3..0.7); |
| |
| let half_pipe = PIPE_IMG_SIZE.1 / 2.; |
| let pipe_upper = center_y + half_distance + (rand_half_gap + half_pipe); |
| let pipe_down = center_y - half_distance - (gap - rand_half_gap + half_pipe); |
| |
| |
| commands.spawn(( |
| SpriteBundle { |
| texture: static_assets.pipe.clone(), |
| transform: Transform { |
| translation: Vec3 { |
| x, |
| y: pipe_down, |
| z: 2., |
| }, |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| Velocity { |
| x: -PLAYER_X_MAX_VELOCITY, |
| y: 0., |
| }, |
| Movable { |
| need_rotation: false, |
| }, |
| Obstacle, |
| )); |
| |
| |
| commands.spawn(( |
| SpriteBundle { |
| texture: static_assets.pipe.clone(), |
| transform: Transform { |
| translation: Vec3 { |
| x, |
| y: pipe_upper, |
| z: 2., |
| }, |
| ..Default::default() |
| }, |
| sprite: Sprite { |
| flip_y: true, |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| Velocity { |
| x: -PLAYER_X_MAX_VELOCITY, |
| y: 0., |
| }, |
| Movable { |
| need_rotation: false, |
| }, |
| Obstacle, |
| )); |
| } |
| } |
| |
| fn spawn_obstacle_system( |
| mut commands: Commands, |
| mut game_data: ResMut<GameData>, |
| static_assets: Res<StaticAssets>, |
| win_size: Res<WinSize>, |
| ) { |
| if !game_data.need_spawn_obstacle() { |
| return; |
| } |
| game_data.obstacle_call_back(); |
| let mut rng = thread_rng(); |
| |
| let x = win_size.width / 2. + PIPE_IMG_SIZE.0 / 2.; |
| |
| let center_y = (win_size.height - BACKGROUND_IMG_SIZE.1) / 2.; |
| |
| |
| |
| let reasonable_y_max = win_size.height / 2. - 100.; |
| let reasonable_y_min = -(win_size.height / 2. - 100. - GROUND_IMG_SIZE.1); |
| |
| let point_y = rng.gen_range(reasonable_y_min..reasonable_y_max); |
| let half_distance = (center_y - point_y).abs() / 2.; |
| |
| |
| let score = game_data.get_score(); |
| let max = GAP_MAX - score as f32 / 10.; |
| |
| |
| let max = max.max(GAP_MIN); |
| let min = GAP_MIN; |
| let gap = rng.gen_range(min.min(max)..min.max(max)); |
| let rand_half_gap = gap * rng.gen_range(0.3..0.7); |
| |
| let half_pipe = PIPE_IMG_SIZE.1 / 2.; |
| let pipe_upper = center_y + half_distance + (rand_half_gap + half_pipe); |
| let pipe_down = center_y - half_distance - (gap - rand_half_gap + half_pipe); |
| |
| |
| commands.spawn(( |
| SpriteBundle { |
| texture: static_assets.pipe.clone(), |
| transform: Transform { |
| translation: Vec3 { |
| x, |
| y: pipe_down, |
| z: 2., |
| }, |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| Velocity { |
| x: -PLAYER_X_MAX_VELOCITY, |
| y: 0., |
| }, |
| Movable { |
| need_rotation: false, |
| }, |
| Obstacle, |
| )); |
| |
| |
| commands.spawn(( |
| SpriteBundle { |
| texture: static_assets.pipe.clone(), |
| transform: Transform { |
| translation: Vec3 { |
| x, |
| y: pipe_upper, |
| z: 2., |
| }, |
| ..Default::default() |
| }, |
| sprite: Sprite { |
| flip_y: true, |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| Velocity { |
| x: -PLAYER_X_MAX_VELOCITY, |
| y: 0., |
| }, |
| Movable { |
| need_rotation: false, |
| }, |
| Obstacle, |
| )); |
| } |
| |
player.rs
| use bevy::{ |
| prelude::{ |
| Audio, Commands, Input, IntoSystemAppConfig, IntoSystemConfigs, KeyCode, OnEnter, OnUpdate, |
| Plugin, Query, Res, ResMut, Transform, Vec3, With, |
| }, |
| sprite::{SpriteSheetBundle, TextureAtlasSprite}, |
| time::{Timer, TimerMode}, |
| }; |
| |
| use crate::{ |
| components::{Movable, Player, PlayerAnimationTimer, Velocity}, |
| constants::{ |
| GRAVITY_VELOCITY, PLAYER_Y_MAX_UP_VELOCITY, PLAYER_Y_MAX_VELOCITY, PLAYER_Y_UP_PIXEL, |
| TIME_STEP, |
| }, |
| resource::{GameData, StaticAssets, WinSize}, |
| state::GameState, |
| }; |
| |
| pub struct PlayerPlugin; |
| |
| impl Plugin for PlayerPlugin { |
| fn build(&self, app: &mut bevy::prelude::App) { |
| app.add_systems( |
| (input_key_system, bird_automatic_system).in_set(OnUpdate(GameState::InGame)), |
| ) |
| .add_system(spawn_bird_system.in_schedule(OnEnter(GameState::InGame))); |
| } |
| } |
| |
| |
| fn spawn_bird_system( |
| mut commands: Commands, |
| win_size: Res<WinSize>, |
| static_assets: Res<StaticAssets>, |
| mut game_data: ResMut<GameData>, |
| ) { |
| if !game_data.player_alive() { |
| let bird = static_assets.player.clone(); |
| let (x, y) = (-win_size.width / 4. / 2., win_size.height / 2. / 3.); |
| commands.spawn(( |
| SpriteSheetBundle { |
| texture_atlas: bird, |
| transform: Transform { |
| translation: Vec3 { x, y, z: 2. }, |
| ..Default::default() |
| }, |
| sprite: TextureAtlasSprite::new(0), |
| ..Default::default() |
| }, |
| Player, |
| Velocity { x: 0., y: 0. }, |
| Movable { |
| need_rotation: true, |
| }, |
| PlayerAnimationTimer(Timer::from_seconds(0.2, TimerMode::Repeating)), |
| )); |
| game_data.alive(); |
| } |
| } |
| |
| |
| fn input_key_system( |
| kb: Res<Input<KeyCode>>, |
| static_assets: Res<StaticAssets>, |
| audio_player: Res<Audio>, |
| mut query: Query<(&mut Velocity, &mut Transform), With<Player>>, |
| ) { |
| if kb.just_released(KeyCode::Space) { |
| let vt = query.get_single_mut(); |
| |
| match vt { |
| Ok((mut velocity, mut transform)) => { |
| transform.translation.y += PLAYER_Y_UP_PIXEL; |
| velocity.y = PLAYER_Y_MAX_UP_VELOCITY; |
| } |
| _ => (), |
| } |
| audio_player.play(static_assets.fly_audio.clone()); |
| } |
| } |
| |
| |
| fn bird_automatic_system(mut query: Query<&mut Velocity, (With<Player>, With<Movable>)>) { |
| for mut velocity in query.iter_mut() { |
| velocity.y = velocity.y - GRAVITY_VELOCITY * TIME_STEP; |
| if velocity.y < -PLAYER_Y_MAX_VELOCITY { |
| velocity.y = -PLAYER_Y_MAX_VELOCITY; |
| } |
| } |
| } |
| |
resource.rs
| use bevy::{ |
| prelude::{AudioSource, Handle, Image, Resource}, |
| sprite::TextureAtlas, |
| text::Font, |
| }; |
| |
| |
| #[derive(Resource)] |
| pub struct GameData { |
| score: u8, |
| alive: bool, |
| need_add_obstacle: bool, |
| } |
| impl GameData { |
| pub fn new() -> Self { |
| Self { |
| score: 0, |
| alive: false, |
| need_add_obstacle: false, |
| } |
| } |
| |
| pub fn need_spawn_obstacle(&self) -> bool { |
| self.need_add_obstacle |
| } |
| |
| pub fn obstacle_call_back(&mut self) { |
| self.need_add_obstacle = false; |
| } |
| |
| pub fn call_obstacle_spawn(&mut self) { |
| self.need_add_obstacle = true; |
| } |
| |
| pub fn alive(&mut self) { |
| self.alive = true; |
| } |
| |
| pub fn death(&mut self) { |
| self.alive = false; |
| self.score = 0; |
| } |
| |
| pub fn get_score(&self) -> u8 { |
| self.score |
| } |
| |
| pub fn add_score(&mut self) { |
| self.score += 1; |
| } |
| |
| pub fn player_alive(&self) -> bool { |
| self.alive |
| } |
| } |
| |
| |
| #[derive(Resource)] |
| pub struct WinSize { |
| pub width: f32, |
| pub height: f32, |
| } |
| |
| |
| #[derive(Resource)] |
| pub struct StaticAssets { |
| |
| |
| pub player: Handle<TextureAtlas>, |
| |
| pub pipe: Handle<Image>, |
| |
| pub background: Handle<Image>, |
| |
| pub ground: Handle<Image>, |
| |
| |
| |
| pub fly_audio: Handle<AudioSource>, |
| |
| pub die_audio: Handle<AudioSource>, |
| |
| pub point_audio: Handle<AudioSource>, |
| |
| pub hit_audio: Handle<AudioSource>, |
| |
| |
| |
| pub kenney_future_font: Handle<Font>, |
| } |
| |
state.rs
| use bevy::{ |
| prelude::{ |
| Color, Commands, Entity, Input, IntoSystemAppConfig, IntoSystemConfig, KeyCode, NextState, |
| OnEnter, OnExit, OnUpdate, Plugin, Query, Res, ResMut, States, Transform, Vec3, With, |
| }, |
| text::{Text, Text2dBundle, TextAlignment, TextSection, TextStyle}, |
| }; |
| |
| use crate::{ |
| components::{DisplayGameOver, DisplayMenu, DisplayScore, Obstacle, Player}, |
| constants::GROUND_IMG_SIZE, |
| resource::{GameData, StaticAssets, WinSize}, |
| }; |
| |
| #[derive(Debug, Default, States, PartialEq, Eq, Clone, Hash)] |
| pub enum GameState { |
| #[default] |
| Menu, |
| InGame, |
| Paused, |
| GameOver, |
| } |
| |
| pub struct StatesPlugin; |
| |
| impl Plugin for StatesPlugin { |
| fn build(&self, app: &mut bevy::prelude::App) { |
| app |
| |
| .add_system(menu_display_system.in_schedule(OnEnter(GameState::Menu))) |
| .add_system(enter_game_system.in_set(OnUpdate(GameState::Menu))) |
| .add_system(exit_menu.in_schedule(OnExit(GameState::Menu))) |
| |
| .add_system(enter_paused_system.in_schedule(OnEnter(GameState::Paused))) |
| .add_system(paused_input_system.in_set(OnUpdate(GameState::Paused))) |
| .add_system(paused_exit_system.in_schedule(OnExit(GameState::Paused))) |
| |
| .add_system(in_game_display_system.in_schedule(OnEnter(GameState::InGame))) |
| .add_system(in_game_input_system.in_set(OnUpdate(GameState::InGame))) |
| .add_system(exit_game_system.in_schedule(OnExit(GameState::InGame))) |
| |
| .add_system(game_over_enter_system.in_schedule(OnEnter(GameState::GameOver))) |
| .add_system(in_game_over_system.in_set(OnUpdate(GameState::GameOver))) |
| .add_system(game_over_exit_system.in_schedule(OnExit(GameState::GameOver))); |
| } |
| } |
| |
| |
| fn menu_display_system(mut commands: Commands, static_assets: Res<StaticAssets>) { |
| let font = static_assets.kenney_future_font.clone(); |
| let common_style = TextStyle { |
| font: font.clone(), |
| font_size: 32., |
| color: Color::BLUE, |
| ..Default::default() |
| }; |
| let special_style = TextStyle { |
| font: font.clone(), |
| font_size: 38., |
| color: Color::RED, |
| ..Default::default() |
| }; |
| |
| let align = TextAlignment::Center; |
| commands.spawn(( |
| Text2dBundle { |
| text: Text::from_sections(vec![ |
| TextSection::new("PRESS \r\n".to_owned(), common_style.clone()), |
| TextSection::new(" SPACE \r\n".to_owned(), special_style.clone()), |
| TextSection::new("START GAME!\r\n".to_owned(), common_style.clone()), |
| TextSection::new(" P \r\n".to_owned(), special_style.clone()), |
| TextSection::new("PAUSED GAME!\r\n".to_owned(), common_style.clone()), |
| ]) |
| .with_alignment(align), |
| transform: Transform { |
| translation: Vec3::new(0., 0., 4.), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| DisplayMenu, |
| )); |
| } |
| |
| |
| fn in_game_display_system( |
| mut commands: Commands, |
| win_size: Res<WinSize>, |
| static_assets: Res<StaticAssets>, |
| ) { |
| let font = static_assets.kenney_future_font.clone(); |
| let common_style = TextStyle { |
| font: font.clone(), |
| font_size: 32., |
| color: Color::BLUE, |
| ..Default::default() |
| }; |
| let special_style = TextStyle { |
| font: font.clone(), |
| font_size: 38., |
| color: Color::RED, |
| ..Default::default() |
| }; |
| let y = -(win_size.height / 2. - GROUND_IMG_SIZE.1 + special_style.font_size * 1.5); |
| let align = TextAlignment::Center; |
| commands.spawn(( |
| Text2dBundle { |
| text: Text::from_sections(vec![ |
| TextSection::new("SCORE: ".to_owned(), common_style), |
| TextSection::new("0".to_owned(), special_style), |
| ]) |
| .with_alignment(align), |
| transform: Transform { |
| translation: Vec3::new(0., y, 6.), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| DisplayScore, |
| )); |
| } |
| |
| |
| fn enter_game_system(kb: Res<Input<KeyCode>>, mut state: ResMut<NextState<GameState>>) { |
| if kb.just_released(KeyCode::Space) { |
| state.set(GameState::InGame) |
| } |
| } |
| |
| |
| fn exit_game_system( |
| mut commands: Commands, |
| query: Query<Entity, (With<Text>, With<DisplayScore>)>, |
| ) { |
| for entity in query.iter() { |
| commands.entity(entity).despawn(); |
| } |
| } |
| |
| fn exit_menu(mut commands: Commands, query: Query<Entity, (With<Text>, With<DisplayMenu>)>) { |
| for entity in query.iter() { |
| commands.entity(entity).despawn(); |
| } |
| } |
| |
| |
| pub fn enter_paused_system(mut commands: Commands, static_assets: Res<StaticAssets>) { |
| |
| let font = static_assets.kenney_future_font.clone(); |
| let common_style = TextStyle { |
| font: font.clone(), |
| font_size: 32., |
| color: Color::BLUE, |
| ..Default::default() |
| }; |
| let special_style = TextStyle { |
| font: font.clone(), |
| font_size: 38., |
| color: Color::RED, |
| ..Default::default() |
| }; |
| |
| let align = TextAlignment::Center; |
| commands.spawn(( |
| Text2dBundle { |
| text: Text::from_sections(vec![ |
| TextSection::new("PAUSED \r\n".to_owned(), common_style.clone()), |
| TextSection::new(" R \r\n".to_owned(), special_style.clone()), |
| TextSection::new("RETURN GAME!".to_owned(), common_style.clone()), |
| ]) |
| .with_alignment(align), |
| transform: Transform { |
| translation: Vec3::new(0., 0., 4.), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| DisplayMenu, |
| )); |
| } |
| |
| |
| pub fn paused_input_system(kb: Res<Input<KeyCode>>, mut next_state: ResMut<NextState<GameState>>) { |
| if kb.pressed(KeyCode::R) { |
| next_state.set(GameState::InGame); |
| } |
| } |
| |
| |
| pub fn paused_exit_system( |
| mut commands: Commands, |
| query: Query<Entity, (With<Text>, With<DisplayMenu>)>, |
| ) { |
| for entity in query.iter() { |
| commands.entity(entity).despawn(); |
| } |
| } |
| |
| |
| pub fn in_game_input_system(kb: Res<Input<KeyCode>>, mut next_state: ResMut<NextState<GameState>>) { |
| if kb.pressed(KeyCode::P) { |
| next_state.set(GameState::Paused); |
| } |
| } |
| |
| |
| pub fn game_over_enter_system( |
| mut commands: Commands, |
| game_data: Res<GameData>, |
| static_assets: Res<StaticAssets>, |
| ) { |
| |
| let font = static_assets.kenney_future_font.clone(); |
| let common_style = TextStyle { |
| font: font.clone(), |
| font_size: 32., |
| color: Color::BLUE, |
| ..Default::default() |
| }; |
| let special_style = TextStyle { |
| font: font.clone(), |
| font_size: 38., |
| color: Color::RED, |
| ..Default::default() |
| }; |
| |
| let align = TextAlignment::Center; |
| commands.spawn(( |
| Text2dBundle { |
| text: Text::from_sections(vec![ |
| TextSection::new( |
| "GAME OVER ! \r\n You got ".to_owned(), |
| common_style.clone(), |
| ), |
| TextSection::new(game_data.get_score().to_string(), special_style.clone()), |
| TextSection::new(" score. \r\n ".to_owned(), common_style.clone()), |
| TextSection::new("SPACE ".to_owned(), special_style.clone()), |
| TextSection::new("RESTART GAME! \r\n".to_owned(), common_style.clone()), |
| TextSection::new("M ".to_owned(), special_style.clone()), |
| TextSection::new("TO MENU".to_owned(), common_style.clone()), |
| ]) |
| .with_alignment(align), |
| transform: Transform { |
| translation: Vec3::new(0., 80., 4.), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| DisplayGameOver, |
| )); |
| } |
| |
| |
| pub fn game_over_exit_system( |
| mut commands: Commands, |
| query: Query<Entity, (With<Text>, With<DisplayGameOver>)>, |
| obstacle_query: Query<Entity, With<Obstacle>>, |
| player_query: Query<Entity, With<Player>>, |
| ) { |
| for entity in query.iter() { |
| commands.entity(entity).despawn(); |
| } |
| for entity in obstacle_query.iter() { |
| commands.entity(entity).despawn(); |
| } |
| for entity in player_query.iter() { |
| commands.entity(entity).despawn(); |
| } |
| } |
| |
| |
| pub fn in_game_over_system( |
| kb: Res<Input<KeyCode>>, |
| mut game_data: ResMut<GameData>, |
| mut next_state: ResMut<NextState<GameState>>, |
| ) { |
| game_data.death(); |
| if kb.pressed(KeyCode::M) { |
| next_state.set(GameState::Menu); |
| } else if kb.pressed(KeyCode::Space) { |
| next_state.set(GameState::InGame); |
| } |
| } |
Cargo.toml
| [package] |
| name = "flappy_bird_bevy" |
| version = "0.1.0" |
| edition = "2021" |
| |
| |
| |
| [dependencies] |
| bevy = { version = "0.10.1" } |
| rand = "0.8.5" |
| |
| [workspace] |
| resolver = "2" |
about me
目前失业,在家学习 rust 。
我的 bilibili,我的 Github。
Rust官网
Rust 中文社区
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现